From 32b479467a08d42431a527afe96d642855b04306 Mon Sep 17 00:00:00 2001 From: Jesse Read Date: Sat, 31 Aug 2013 20:45:37 -0400 Subject: [PATCH 01/55] Fix missed type/protocol change. Fixes torrents being created as .movie files. --- couchpotato/core/plugins/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/couchpotato/core/plugins/base.py b/couchpotato/core/plugins/base.py index 89ef29b..1ece927 100644 --- a/couchpotato/core/plugins/base.py +++ b/couchpotato/core/plugins/base.py @@ -294,7 +294,7 @@ class Plugin(object): name = os.path.join(self.createNzbName(data, movie)) if 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'): From 910578a2ac55fbdf6b10d79824f40ac0fa166811 Mon Sep 17 00:00:00 2001 From: Ruud Date: Mon, 2 Sep 2013 14:10:31 +0200 Subject: [PATCH 02/55] Use TheMovieDB v3 api --- couchpotato/core/plugins/scanner/main.py | 11 - couchpotato/core/providers/info/themoviedb/main.py | 140 ++-- couchpotato/core/providers/userscript/tmdb/main.py | 2 +- libs/themoviedb/__init__.py | 0 libs/themoviedb/tmdb.py | 740 --------------------- libs/tmdb3/__init__.py | 11 + libs/tmdb3/cache.py | 121 ++++ libs/tmdb3/cache_engine.py | 72 ++ libs/tmdb3/cache_file.py | 391 +++++++++++ libs/tmdb3/cache_null.py | 19 + libs/tmdb3/locales.py | 634 ++++++++++++++++++ libs/tmdb3/pager.py | 109 +++ libs/tmdb3/request.py | 157 +++++ libs/tmdb3/tmdb_api.py | 689 +++++++++++++++++++ libs/tmdb3/tmdb_auth.py | 131 ++++ libs/tmdb3/tmdb_exceptions.py | 89 +++ libs/tmdb3/util.py | 366 ++++++++++ 17 files changed, 2829 insertions(+), 853 deletions(-) delete mode 100644 libs/themoviedb/__init__.py delete mode 100644 libs/themoviedb/tmdb.py create mode 100755 libs/tmdb3/__init__.py create mode 100755 libs/tmdb3/cache.py create mode 100755 libs/tmdb3/cache_engine.py create mode 100755 libs/tmdb3/cache_file.py create mode 100755 libs/tmdb3/cache_null.py create mode 100755 libs/tmdb3/locales.py create mode 100755 libs/tmdb3/pager.py create mode 100755 libs/tmdb3/request.py create mode 100755 libs/tmdb3/tmdb_api.py create mode 100755 libs/tmdb3/tmdb_auth.py create mode 100755 libs/tmdb3/tmdb_exceptions.py create mode 100755 libs/tmdb3/util.py diff --git a/couchpotato/core/plugins/scanner/main.py b/couchpotato/core/plugins/scanner/main.py index b9cc579..553fa83 100644 --- a/couchpotato/core/plugins/scanner/main.py +++ b/couchpotato/core/plugins/scanner/main.py @@ -594,17 +594,6 @@ class Scanner(Plugin): 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']: diff --git a/couchpotato/core/providers/info/themoviedb/main.py b/couchpotato/core/providers/info/themoviedb/main.py index e2ff937..b25b841 100644 --- a/couchpotato/core/providers/info/themoviedb/main.py +++ b/couchpotato/core/providers/info/themoviedb/main.py @@ -1,8 +1,11 @@ from couchpotato.core.event import addEvent -from couchpotato.core.helpers.encoding import simplifyString, toUnicode +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 -from themoviedb import tmdb +from couchpotato.environment import Env +import os +import tmdb3 import traceback log = CPLog(__name__) @@ -11,44 +14,13 @@ 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) + addEvent('movie.info_by_tmdb', self.getInfo) - # 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 + # Configure TMDB settings + tmdb3.set_key(self.conf('api_key')) + tmdb3.set_cache(engine='file', filename=os.path.join(Env.get('cache_dir'), 'python', 'tmdb.cache')) def search(self, q, limit = 12): ''' Find movie by name ''' @@ -65,7 +37,7 @@ class TheMovieDb(MovieProvider): raw = None try: - raw = tmdb.search(search_string) + raw = tmdb3.searchMovie(search_string) except: log.error('Failed searching TMDB for "%s": %s', (search_string, traceback.format_exc())) @@ -75,7 +47,7 @@ class TheMovieDb(MovieProvider): nr = 0 for movie in raw: - results.append(self.parseMovie(movie)) + results.append(self.parseMovie(movie, with_titles = False)) nr += 1 if nr == limit: @@ -83,7 +55,7 @@ class TheMovieDb(MovieProvider): log.info('Found: %s', [result['titles'][0] + ' (' + str(result.get('year', 0)) + ')' for result in results]) - self.setCache(cache_key, results) + self.setCache(md5(ss(cache_key)), results) return results except SyntaxError, e: log.error('Failed to parse XML response: %s', e) @@ -105,109 +77,75 @@ class TheMovieDb(MovieProvider): try: log.debug('Getting info: %s', cache_key) - movie = tmdb.imdbLookup(id = identifier) - except: - pass - - if movie: - result = self.parseMovie(movie[0]) + movie = tmdb3.Movie(identifier) + result = self.parseMovie(movie) 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): + def parseMovie(self, movie, with_titles = True): # Images - poster = self.getImage(movie, type = 'poster', size = 'cover') - #backdrop = self.getImage(movie, type = 'backdrop', size = 'w1280') + 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 = self.getCategory(movie, 'genre') + genres = [genre.name for genre in movie.genres] except: genres = [] # 1900 is the same as None - year = str(movie.get('released', 'none'))[:4] - if year == '1900' or year.lower() == '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': int(movie.get('id', 0)), - 'titles': [toUnicode(movie.get('name'))], - 'original_title': movie.get('original_name'), + '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.get('imdb_id'), - 'mpaa': movie.get('certification', ''), - 'runtime': movie.get('runtime'), - 'released': movie.get('released'), + 'imdb': movie.imdb, + 'runtime': movie.runtime, + 'released': movie.releasedate, 'year': year, - 'plot': movie.get('overview'), + 'plot': movie.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) + 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 != None: + movie_data['titles'].append(alt_name) + + movie_data['titles'] = list(set(movie_data['titles'])) return movie_data - def getImage(self, movie, type = 'poster', size = 'cover'): + def getImage(self, movie, type = 'poster', size = 'poster'): image_url = '' - for image in movie.get('images', []): - if(image.get('type') == type) and image.get(size): - image_url = image.get(size) - break + 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 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.') diff --git a/couchpotato/core/providers/userscript/tmdb/main.py b/couchpotato/core/providers/userscript/tmdb/main.py index 6205851..cab38fc 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/libs/themoviedb/__init__.py b/libs/themoviedb/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/libs/themoviedb/tmdb.py b/libs/themoviedb/tmdb.py deleted file mode 100644 index 6a0e591..0000000 --- a/libs/themoviedb/tmdb.py +++ /dev/null @@ -1,740 +0,0 @@ -#!/usr/bin/env python -#-*- coding:utf-8 -*- -#author:doganaydin /// forked from dbr/Ben -#project:themoviedb -#repository:http://github.com/doganaydin/themoviedb -#license: LGPLv2 http://www.gnu.org/licenses/lgpl.html - -"""An interface to the themoviedb.org API""" - -__author__ = "doganaydin" -__version__ = "0.5" - - -config = {} - -def configure(api_key): - config['apikey'] = api_key - config['urls'] = {} - config['urls']['movie.search'] = "http://api.themoviedb.org/2.1/Movie.search/en/xml/%(apikey)s/%%s" % (config) - config['urls']['movie.getInfo'] = "http://api.themoviedb.org/2.1/Movie.getInfo/en/xml/%(apikey)s/%%s" % (config) - config['urls']['media.getInfo'] = "http://api.themoviedb.org/2.1/Media.getInfo/en/xml/%(apikey)s/%%s/%%s" % (config) - config['urls']['imdb.lookUp'] = "http://api.themoviedb.org/2.1/Movie.imdbLookup/en/xml/%(apikey)s/%%s" % (config) - config['urls']['movie.browse'] = "http://api.themoviedb.org/2.1/Movie.browse/en-US/xml/%(apikey)s?%%s" % (config) - -import os, struct, urllib, urllib2, xml.etree.cElementTree as ElementTree - -class TmdBaseError(Exception): - pass - -class TmdNoResults(TmdBaseError): - pass - -class TmdHttpError(TmdBaseError): - pass - -class TmdXmlError(TmdBaseError): - pass - -class TmdConfigError(TmdBaseError): - pass - -def opensubtitleHashFile(name): - """Hashes a file using OpenSubtitle's method. - > In natural language it calculates: size + 64bit chksum of the first and - > last 64k (even if they overlap because the file is smaller than 128k). - A slightly more Pythonic version of the Python solution on.. - http://trac.opensubtitles.org/projects/opensubtitles/wiki/HashSourceCodes - """ - longlongformat = 'q' - bytesize = struct.calcsize(longlongformat) - - f = open(name, "rb") - - filesize = os.path.getsize(name) - fhash = filesize - - if filesize < 65536 * 2: - raise ValueError("File size must be larger than %s bytes (is %s)" % (65536 * 2, filesize)) - - for x in range(65536 / bytesize): - buf = f.read(bytesize) - (l_value,) = struct.unpack(longlongformat, buf) - fhash += l_value - fhash = fhash & 0xFFFFFFFFFFFFFFFF # to remain as 64bit number - - f.seek(max(0, filesize - 65536), 0) - for x in range(65536 / bytesize): - buf = f.read(bytesize) - (l_value,) = struct.unpack(longlongformat, buf) - fhash += l_value - fhash = fhash & 0xFFFFFFFFFFFFFFFF - - f.close() - return "%016x" % fhash - -class XmlHandler: - """Deals with retrieval of XML files from API""" - def __init__(self, url): - self.url = url - - def _grabUrl(self, url): - try: - urlhandle = urllib2.urlopen(url) - except IOError, errormsg: - raise TmdHttpError(errormsg) - if urlhandle.code >= 400: - raise TmdHttpError("HTTP status code was %d" % urlhandle.code) - return urlhandle.read() - - def getEt(self): - xml = self._grabUrl(self.url) - try: - et = ElementTree.fromstring(xml) - except SyntaxError, errormsg: - raise TmdXmlError(errormsg) - return et - -class SearchResults(list): - """Stores a list of Movie's that matched the search""" - def __repr__(self): - return "" % (list.__repr__(self)) - -class MovieResult(dict): - """A dict containing the information about a specific search result""" - def __repr__(self): - return "" % (self.get("name"), self.get("released")) - - def info(self): - """Performs a MovieDb.getMovieInfo search on the current id, returns - a Movie object - """ - cur_id = self['id'] - info = MovieDb().getMovieInfo(cur_id) - return info - -class Movie(dict): - """A dict containing the information about the film""" - def __repr__(self): - return "" % (self.get("name"), self.get("released")) - -class Categories(dict): - """Stores category information""" - def set(self, category_et): - """Takes an elementtree Element ('category') and stores the url, - using the type and name as the dict key. - For example: - - ..becomes: - categories['genre']['Crime'] = 'http://themoviedb.org/encyclopedia/category/80' - """ - _type = category_et.get("type") - name = category_et.get("name") - url = category_et.get("url") - self.setdefault(_type, {})[name] = url - self[_type][name] = url - -class Studios(dict): - """Stores category information""" - def set(self, studio_et): - """Takes an elementtree Element ('studio') and stores the url, - using the name as the dict key. - For example: - - ..becomes: - studios['name'] = 'http://www.themoviedb.org/encyclopedia/company/20' - """ - name = studio_et.get("name") - url = studio_et.get("url") - self[name] = url - -class Countries(dict): - """Stores country information""" - def set(self, country_et): - """Takes an elementtree Element ('country') and stores the url, - using the name and code as the dict key. - For example: - - ..becomes: - countries['code']['name'] = 'http://www.themoviedb.org/encyclopedia/country/223' - """ - code = country_et.get("code") - name = country_et.get("name") - url = country_et.get("url") - self.setdefault(code, {})[name] = url - -class Image(dict): - """Stores image information for a single poster/backdrop (includes - multiple sizes) - """ - def __init__(self, _id, _type, size, url): - self['id'] = _id - self['type'] = _type - - def largest(self): - for csize in ["original", "mid", "cover", "thumb"]: - if csize in self: - return csize - - def __repr__(self): - return "" % (self['type'], self['id']) - -class ImagesList(list): - """Stores a list of Images, and functions to filter "only posters" etc""" - def set(self, image_et): - """Takes an elementtree Element ('image') and stores the url, - along with the type, id and size. - Is a list containing each image as a dictionary (which includes the - various sizes) - For example: - - ..becomes: - images[0] = {'id':4181', 'type': 'poster', 'original': 'http://images.themov...'} - """ - _type = image_et.get("type") - _id = image_et.get("id") - size = image_et.get("size") - url = image_et.get("url") - cur = self.find_by('id', _id) - if len(cur) == 0: - nimg = Image(_id = _id, _type = _type, size = size, url = url) - self.append(nimg) - elif len(cur) == 1: - cur[0][size] = url - else: - raise ValueError("Found more than one poster with id %s, this should never happen" % (_id)) - - def find_by(self, key, value): - ret = [] - for cur in self: - if cur[key] == value: - ret.append(cur) - return ret - - @property - def posters(self): - return self.find_by('type', 'poster') - - @property - def backdrops(self): - return self.find_by('type', 'backdrop') - -class CrewRoleList(dict): - """Stores a list of roles, such as director, actor etc - >>> import tmdb - >>> tmdb.getMovieInfo(550)['cast'].keys()[:5] - ['casting', 'producer', 'author', 'sound editor', 'actor'] - """ - pass - -class CrewList(list): - """Stores list of crew in specific role - >>> import tmdb - >>> tmdb.getMovieInfo(550)['cast']['author'] - [, ] - """ - pass - -class Person(dict): - """Stores information about a specific member of cast""" - def __init__(self, job, _id, name, character, url): - self['job'] = job - self['id'] = _id - self['name'] = name - self['character'] = character - self['url'] = url - - def __repr__(self): - if self['character'] is None or self['character'] == "": - return "<%(job)s (id %(id)s): %(name)s>" % self - else: - return "<%(job)s (id %(id)s): %(name)s (as %(character)s)>" % self - -class MovieDb: - """Main interface to www.themoviedb.com - The search() method searches for the film by title. - The getMovieInfo() method retrieves information about a specific movie using themoviedb id. - """ - def _parseSearchResults(self, movie_element): - cur_movie = MovieResult() - cur_images = ImagesList() - for item in movie_element.getchildren(): - if item.tag.lower() == "images": - for subitem in item.getchildren(): - cur_images.set(subitem) - else: - cur_movie[item.tag] = item.text - cur_movie['images'] = cur_images - return cur_movie - - def _parseMovie(self, movie_element): - cur_movie = Movie() - cur_categories = Categories() - cur_studios = Studios() - cur_countries = Countries() - cur_images = ImagesList() - cur_cast = CrewRoleList() - for item in movie_element.getchildren(): - if item.tag.lower() == "categories": - for subitem in item.getchildren(): - cur_categories.set(subitem) - elif item.tag.lower() == "studios": - for subitem in item.getchildren(): - cur_studios.set(subitem) - elif item.tag.lower() == "countries": - for subitem in item.getchildren(): - cur_countries.set(subitem) - elif item.tag.lower() == "images": - for subitem in item.getchildren(): - cur_images.set(subitem) - elif item.tag.lower() == "cast": - for subitem in item.getchildren(): - job = subitem.get("job").lower() - p = Person( - job = job, - _id = subitem.get("id"), - name = subitem.get("name"), - character = subitem.get("character"), - url = subitem.get("url"), - ) - cur_cast.setdefault(job, CrewList()).append(p) - else: - cur_movie[item.tag] = item.text - - cur_movie['categories'] = cur_categories - cur_movie['studios'] = cur_studios - cur_movie['countries'] = cur_countries - cur_movie['images'] = cur_images - cur_movie['cast'] = cur_cast - return cur_movie - - def search(self, title): - """Searches for a film by its title. - Returns SearchResults (a list) containing all matches (Movie instances) - """ - title = urllib.quote(title.encode("utf-8")) - url = config['urls']['movie.search'] % (title) - etree = XmlHandler(url).getEt() - search_results = SearchResults() - for cur_result in etree.find("movies").findall("movie"): - cur_movie = self._parseSearchResults(cur_result) - search_results.append(cur_movie) - return search_results - - def getMovieInfo(self, id): - """Returns movie info by it's TheMovieDb ID. - Returns a Movie instance - """ - url = config['urls']['movie.getInfo'] % (id) - etree = XmlHandler(url).getEt() - moviesTree = etree.find("movies").findall("movie") - - if len(moviesTree) == 0: - raise TmdNoResults("No results for id %s" % id) - return self._parseMovie(moviesTree[0]) - - def mediaGetInfo(self, hash, size): - """Used to retrieve specific information about a movie but instead of - passing a TMDb ID, you pass a file hash and filesize in bytes - """ - url = config['urls']['media.getInfo'] % (hash, size) - etree = XmlHandler(url).getEt() - moviesTree = etree.find("movies").findall("movie") - if len(moviesTree) == 0: - raise TmdNoResults("No results for hash %s" % hash) - return [self._parseMovie(x) for x in moviesTree] - - def imdbLookup(self, id = 0, title = False): - if not config.get('apikey'): - raise TmdConfigError("API Key not set") - if id > 0: - url = config['urls']['imdb.lookUp'] % (id) - else: - _imdb_id = self.search(title)[0]["imdb_id"] - url = config['urls']['imdb.lookUp'] % (_imdb_id) - etree = XmlHandler(url).getEt() - lookup_results = SearchResults() - for cur_lookup in etree.find("movies").findall("movie"): - cur_movie = self._parseSearchResults(cur_lookup) - lookup_results.append(cur_movie) - return lookup_results - -class Browse: - - def __init__(self, params = {}): - """ - tmdb.Browse(params) - default params = {"order_by":"release","order":"desc"} - params = {"query":"some query","release_max":"1991",...} - all posible parameters = http://api.themoviedb.org/2.1/methods/Movie.browse - """ - if "order_by" not in params: - params.update({"order_by":"release"}) - if "order" not in params: - params.update({"order":"desc"}) - - self.params = urllib.urlencode(params) - self.movie = self.look(self.params) - - def look(self, look_for): - url = config['urls']['movie.browse'] % (look_for) - etree = XmlHandler(url).getEt() - look_results = SearchResults() - for cur_lookup in etree.find("movies").findall("movie"): - cur_movie = self._parseSearchResults(cur_lookup) - look_results.append(cur_movie) - return look_results - - def _parseSearchResults(self, movie_element): - cur_movie = MovieResult() - cur_images = ImagesList() - for item in movie_element.getchildren(): - if item.tag.lower() == "images": - for subitem in item.getchildren(): - cur_images.set(subitem) - else: - cur_movie[item.tag] = item.text - cur_movie['images'] = cur_images - return cur_movie - - def getTotal(self): - return len(self.movie) - - def getRating(self, i): - return self.movie[i]["rating"] - - def getVotes(self, i): - return self.movie[i]["votes"] - - def getName(self, i): - return self.movie[i]["name"] - - def getLanguage(self, i): - return self.movie[i]["language"] - - def getCertification(self, i): - return self.movie[i]["certification"] - - def getUrl(self, i): - return self.movie[i]["url"] - - def getOverview(self, i): - return self.movie[i]["overview"] - - def getPopularity(self, i): - return self.movie[i]["popularity"] - - def getOriginalName(self, i): - return self.movie[i]["original_name"] - - def getLastModified(self, i): - return self.movie[i]["last_modified_at"] - - def getImdbId(self, i): - return self.movie[i]["imdb_id"] - - def getReleased(self, i): - return self.movie[i]["released"] - - def getScore(self, i): - return self.movie[i]["score"] - - def getAdult(self, i): - return self.movie[i]["adult"] - - def getVersion(self, i): - return self.movie[i]["version"] - - def getTranslated(self, i): - return self.movie[i]["translated"] - - def getType(self, i): - return self.movie[i]["type"] - - def getId(self, i): - return self.movie[i]["id"] - - def getAlternativeName(self, i): - return self.movie[i]["alternative_name"] - - def getPoster(self, i, size): - if size == "thumb" or size == "t": - return self.movie[i]["images"][0]["thumb"] - elif size == "cover" or size == "c": - return self.movie[i]["images"][0]["cover"] - else: - return self.movie[i]["images"][0]["mid"] - - def getBackdrop(self, i, size): - if size == "poster" or size == "p": - return self.movie[i]["images"][1]["poster"] - else: - return self.movie[i]["images"][1]["thumb"] - - - -# Shortcuts for tmdb search method -# using: -# movie = tmdb.tmdb("Sin City") -# print movie.getRating -> 7.0 -class tmdb: - - def __init__(self, name): - """Convenience wrapper for MovieDb.search - so you can do.. - >>> import tmdb - >>> movie = tmdb.tmdb("Fight Club") - >>> ranking = movie.getRanking() or votes = movie.getVotes() - ]> - """ - mdb = MovieDb() - self.movie = mdb.search(name) - - def getTotal(self): - return len(self.movie) - - def getRating(self, i): - return self.movie[i]["rating"] - - def getVotes(self, i): - return self.movie[i]["votes"] - - def getName(self, i): - return self.movie[i]["name"] - - def getLanguage(self, i): - return self.movie[i]["language"] - - def getCertification(self, i): - return self.movie[i]["certification"] - - def getUrl(self, i): - return self.movie[i]["url"] - - def getOverview(self, i): - return self.movie[i]["overview"] - - def getPopularity(self, i): - return self.movie[i]["popularity"] - - def getOriginalName(self, i): - return self.movie[i]["original_name"] - - def getLastModified(self, i): - return self.movie[i]["last_modified_at"] - - def getImdbId(self, i): - return self.movie[i]["imdb_id"] - - def getReleased(self, i): - return self.movie[i]["released"] - - def getScore(self, i): - return self.movie[i]["score"] - - def getAdult(self, i): - return self.movie[i]["adult"] - - def getVersion(self, i): - return self.movie[i]["version"] - - def getTranslated(self, i): - return self.movie[i]["translated"] - - def getType(self, i): - return self.movie[i]["type"] - - def getId(self, i): - return self.movie[i]["id"] - - def getAlternativeName(self, i): - return self.movie[i]["alternative_name"] - - def getPoster(self, i, size): - if size == "thumb" or size == "t": - return self.movie[i]["images"][0]["thumb"] - elif size == "cover" or size == "c": - return self.movie[i]["images"][0]["cover"] - else: - return self.movie[i]["images"][0]["mid"] - - def getBackdrop(self, i, size): - if size == "poster" or size == "p": - return self.movie[i]["images"][1]["poster"] - else: - return self.movie[i]["images"][1]["thumb"] - -# Shortcuts for imdb lookup method -# using: -# movie = tmdb.imdb("Sin City") -# print movie.getRating -> 7.0 -class imdb: - - def __init__(self, id = 0, title = False): - # get first movie if result=0 - """Convenience wrapper for MovieDb.search - so you can do.. - >>> import tmdb - >>> movie = tmdb.imdb(title="Fight Club") # or movie = tmdb.imdb(id=imdb_id) - >>> ranking = movie.getRanking() or votes = movie.getVotes() - ]> - """ - self.id = id - self.title = title - self.mdb = MovieDb() - self.movie = self.mdb.imdbLookup(self.id, self.title) - - def getTotal(self): - return len(self.movie) - - def getRuntime(self, i): - return self.movie[i]["runtime"] - - def getCategories(self): - from xml.dom.minidom import parse - adres = config['urls']['imdb.lookUp'] % self.getImdbId() - d = parse(urllib2.urlopen(adres)) - s = d.getElementsByTagName("categories") - ds = [] - for i in range(len(s[0].childNodes)): - if i % 2 > 0: - ds.append(s[0].childNodes[i].getAttribute("name")) - return ds - - def getRating(self, i): - return self.movie[i]["rating"] - - def getVotes(self, i): - return self.movie[i]["votes"] - - def getName(self, i): - return self.movie[i]["name"] - - def getLanguage(self, i): - return self.movie[i]["language"] - - def getCertification(self, i): - return self.movie[i]["certification"] - - def getUrl(self, i): - return self.movie[i]["url"] - - def getOverview(self, i): - return self.movie[i]["overview"] - - def getPopularity(self, i): - return self.movie[i]["popularity"] - - def getOriginalName(self, i): - return self.movie[i]["original_name"] - - def getLastModified(self, i): - return self.movie[i]["last_modified_at"] - - def getImdbId(self, i): - return self.movie[i]["imdb_id"] - - def getReleased(self, i): - return self.movie[i]["released"] - - def getAdult(self, i): - return self.movie[i]["adult"] - - def getVersion(self, i): - return self.movie[i]["version"] - - def getTranslated(self, i): - return self.movie[i]["translated"] - - def getType(self, i): - return self.movie[i]["type"] - - def getId(self, i): - return self.movie[i]["id"] - - def getAlternativeName(self, i): - return self.movie[i]["alternative_name"] - - def getPoster(self, i, size): - poster = [] - if size == "thumb" or size == "t": - _size = "thumb" - elif size == "cover" or size == "c": - _size = "cover" - else: - _size = "mid" - for a in self.movie[i]["images"]: - if a["type"] == "poster": - poster.append(a[_size]) - return poster - del poster - - def getBackdrop(self, i, size): - backdrop = [] - if size == "thumb" or size == "t": - _size = "thumb" - elif size == "cover" or size == "c": - _size = "cover" - else: - _size = "mid" - for a in self.movie[i]["images"]: - if a["type"] == "backdrop": - backdrop.append(a[_size]) - return backdrop - del backdrop - -def imdbLookup(id = 0, title = False): - """Convenience wrapper for Imdb.Lookup - so you can do.. - >>> import tmdb - >>> tmdb.imdbLookup("Fight Club") - ]> - """ - mdb = MovieDb() - return mdb.imdbLookup(id, title) - -def search(name): - """Convenience wrapper for MovieDb.search - so you can do.. - >>> import tmdb - >>> tmdb.search("Fight Club") - ]> - """ - mdb = MovieDb() - return mdb.search(name) - -def getMovieInfo(id): - """Convenience wrapper for MovieDb.search - so you can do.. - >>> import tmdb - >>> tmdb.getMovieInfo(187) - - """ - mdb = MovieDb() - return mdb.getMovieInfo(id) - -def mediaGetInfo(hash, size): - """Convenience wrapper for MovieDb.mediaGetInfo - so you can do.. - - >>> import tmdb - >>> tmdb.mediaGetInfo('907172e7fe51ba57', size = 742086656)[0] - - """ - mdb = MovieDb() - return mdb.mediaGetInfo(hash, size) - -def searchByHashingFile(filename): - """Searches for the specified file using the OpenSubtitle hashing method - """ - return mediaGetInfo(opensubtitleHashFile(filename), os.path.size(filename)) - -def main(): - results = search("Fight Club") - searchResult = results[0] - movie = getMovieInfo(searchResult['id']) - print movie['name'] - - print "Producers:" - for prodr in movie['cast']['producer']: - print " " * 4, prodr['name'] - print movie['images'] - for genreName in movie['categories']['genre']: - print "%s (%s)" % (genreName, movie['categories']['genre'][genreName]) - -if __name__ == '__main__': - main() diff --git a/libs/tmdb3/__init__.py b/libs/tmdb3/__init__.py new file mode 100755 index 0000000..92ca551 --- /dev/null +++ b/libs/tmdb3/__init__.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python + +from tmdb_api import Configuration, searchMovie, searchMovieWithYear, \ + searchPerson, searchStudio, searchList, searchCollection, \ + Person, Movie, Collection, Genre, List, __version__ +from request import set_key, set_cache +from locales import get_locale, set_locale +from tmdb_auth import get_session, set_session +from cache_engine import CacheEngine +from tmdb_exceptions import * + diff --git a/libs/tmdb3/cache.py b/libs/tmdb3/cache.py new file mode 100755 index 0000000..3b10677 --- /dev/null +++ b/libs/tmdb3/cache.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +#----------------------- +# Name: cache.py +# Python Library +# Author: Raymond Wagner +# Purpose: Caching framework to store TMDb API results +#----------------------- + +from tmdb_exceptions import * +from cache_engine import Engines + +import cache_null +import cache_file + +class Cache( object ): + """ + This class implements a persistent cache, backed in a file specified in + the object creation. The file is protected for safe, concurrent access + by multiple instances using flock. + This cache uses JSON for speed and storage efficiency, so only simple + data types are supported. + Data is stored in a simple format {key:(expiretimestamp, data)} + """ + def __init__(self, engine=None, *args, **kwargs): + self._engine = None + self._data = {} + self._age = 0 + self.configure(engine, *args, **kwargs) + + def _import(self, data=None): + if data is None: + data = self._engine.get(self._age) + for obj in sorted(data, key=lambda x: x.creation): + if not obj.expired: + self._data[obj.key] = obj + self._age = max(self._age, obj.creation) + + def _expire(self): + for k,v in self._data.items(): + if v.expired: + del self._data[k] + + def configure(self, engine, *args, **kwargs): + if engine is None: + engine = 'file' + elif engine not in Engines: + raise TMDBCacheError("Invalid cache engine specified: "+engine) + self._engine = Engines[engine](self) + self._engine.configure(*args, **kwargs) + + def put(self, key, data, lifetime=60*60*12): + # pull existing data, so cache will be fresh when written back out + if self._engine is None: + raise TMDBCacheError("No cache engine configured") + self._expire() + self._import(self._engine.put(key, data, lifetime)) + + def get(self, key): + if self._engine is None: + raise TMDBCacheError("No cache engine configured") + self._expire() + if key not in self._data: + self._import() + try: + return self._data[key].data + except: + return None + + def cached(self, callback): + """ + Returns a decorator that uses a callback to specify the key to use + for caching the responses from the decorated function. + """ + return self.Cached(self, callback) + + class Cached( object ): + def __init__(self, cache, callback, func=None, inst=None): + self.cache = cache + self.callback = callback + self.func = func + self.inst = inst + + if func: + self.__module__ = func.__module__ + self.__name__ = func.__name__ + self.__doc__ = func.__doc__ + + def __call__(self, *args, **kwargs): + if self.func is None: # decorator is waiting to be given a function + if len(kwargs) or (len(args) != 1): + raise TMDBCacheError('Cache.Cached decorator must be called '+\ + 'a single callable argument before it '+\ + 'be used.') + elif args[0] is None: + raise TMDBCacheError('Cache.Cached decorator called before '+\ + 'being given a function to wrap.') + elif not callable(args[0]): + raise TMDBCacheError('Cache.Cached must be provided a '+\ + 'callable object.') + return self.__class__(self.cache, self.callback, args[0]) + elif self.inst.lifetime == 0: + return self.func(*args, **kwargs) + else: + key = self.callback() + data = self.cache.get(key) + if data is None: + data = self.func(*args, **kwargs) + if hasattr(self.inst, 'lifetime'): + self.cache.put(key, data, self.inst.lifetime) + else: + self.cache.put(key, data) + return data + + def __get__(self, inst, owner): + if inst is None: + return self + func = self.func.__get__(inst, owner) + callback = self.callback.__get__(inst, owner) + return self.__class__(self.cache, callback, func, inst) + diff --git a/libs/tmdb3/cache_engine.py b/libs/tmdb3/cache_engine.py new file mode 100755 index 0000000..99ad4cd --- /dev/null +++ b/libs/tmdb3/cache_engine.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +#----------------------- +# Name: cache_engine.py +# Python Library +# Author: Raymond Wagner +# Purpose: Base cache engine class for collecting registered engines +#----------------------- + +import time +from weakref import ref + +class Engines( object ): + def __init__(self): + self._engines = {} + def register(self, engine): + self._engines[engine.__name__] = engine + self._engines[engine.name] = engine + def __getitem__(self, key): + return self._engines[key] + def __contains__(self, key): + return self._engines.__contains__(key) +Engines = Engines() + +class CacheEngineType( type ): + """ + Cache Engine Metaclass that registers new engines against the cache + for named selection and use. + """ + def __init__(mcs, name, bases, attrs): + super(CacheEngineType, mcs).__init__(name, bases, attrs) + if name != 'CacheEngine': + # skip base class + Engines.register(mcs) + +class CacheEngine( object ): + __metaclass__ = CacheEngineType + + name = 'unspecified' + def __init__(self, parent): + self.parent = ref(parent) + def configure(self): + raise RuntimeError + def get(self, date): + raise RuntimeError + def put(self, key, value, lifetime): + raise RuntimeError + def expire(self, key): + raise RuntimeError + +class CacheObject( object ): + """ + Cache object class, containing one stored record. + """ + + def __init__(self, key, data, lifetime=0, creation=None): + self.key = key + self.data = data + self.lifetime = lifetime + self.creation = creation if creation is not None else time.time() + + def __len__(self): + return len(self.data) + + @property + def expired(self): + return (self.remaining == 0) + + @property + def remaining(self): + return max((self.creation + self.lifetime) - time.time(), 0) + diff --git a/libs/tmdb3/cache_file.py b/libs/tmdb3/cache_file.py new file mode 100755 index 0000000..5918071 --- /dev/null +++ b/libs/tmdb3/cache_file.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +#----------------------- +# Name: cache_file.py +# Python Library +# Author: Raymond Wagner +# Purpose: Persistant file-backed cache using /tmp/ to share data +# using flock or msvcrt.locking to allow safe concurrent +# access. +#----------------------- + +import struct +import errno +import json +import os +import io + +from cStringIO import StringIO + +from tmdb_exceptions import * +from cache_engine import CacheEngine, CacheObject + +#################### +# Cache File Format +#------------------ +# cache version (2) unsigned short +# slot count (2) unsigned short +# slot 0: timestamp (8) double +# slot 0: lifetime (4) unsigned int +# slot 0: seek point (4) unsigned int +# slot 1: timestamp +# slot 1: lifetime index slots are IDd by their query date and +# slot 1: seek point are filled incrementally forwards. lifetime +# .... is how long after query date before the item +# .... expires, and seek point is the location of the +# slot N-2: timestamp start of data for that entry. 256 empty slots +# slot N-2: lifetime are pre-allocated, allowing fast updates. +# slot N-2: seek point when all slots are filled, the cache file is +# slot N-1: timestamp rewritten from scrach to add more slots. +# slot N-1: lifetime +# slot N-1: seek point +# block 1 (?) ASCII +# block 2 +# .... blocks are just simple ASCII text, generated +# .... as independent objects by the JSON encoder +# block N-2 +# block N-1 +# +#################### + + +def _donothing(*args, **kwargs): + pass + +try: + import fcntl + class Flock( object ): + """ + Context manager to flock file for the duration the object exists. + Referenced file will be automatically unflocked as the interpreter + exits the context. + Supports an optional callback to process the error and optionally + suppress it. + """ + LOCK_EX = fcntl.LOCK_EX + LOCK_SH = fcntl.LOCK_SH + + def __init__(self, fileobj, operation, callback=None): + self.fileobj = fileobj + self.operation = operation + self.callback = callback + def __enter__(self): + fcntl.flock(self.fileobj, self.operation) + def __exit__(self, exc_type, exc_value, exc_tb): + suppress = False + if callable(self.callback): + suppress = self.callback(exc_type, exc_value, exc_tb) + fcntl.flock(self.fileobj, fcntl.LOCK_UN) + return suppress + + def parse_filename(filename): + if '$' in filename: + # replace any environmental variables + filename = os.path.expandvars(filename) + if filename.startswith('~'): + # check for home directory + return os.path.expanduser(filename) + elif filename.startswith('/'): + # check for absolute path + return filename + # return path with temp directory prepended + return '/tmp/' + filename + +except ImportError: + import msvcrt + class Flock( object ): + LOCK_EX = msvcrt.LK_LOCK + LOCK_SH = msvcrt.LK_LOCK + + def __init__(self, fileobj, operation, callback=None): + self.fileobj = fileobj + self.operation = operation + self.callback = callback + def __enter__(self): + self.size = os.path.getsize(self.fileobj.name) + msvcrt.locking(self.fileobj.fileno(), self.operation, self.size) + def __exit__(self, exc_type, exc_value, exc_tb): + suppress = False + if callable(self.callback): + suppress = self.callback(exc_type, exc_value, exc_tb) + msvcrt.locking(self.fileobj.fileno(), msvcrt.LK_UNLCK, self.size) + return suppress + + def parse_filename(filename): + if '%' in filename: + # replace any environmental variables + filename = os.path.expandvars(filename) + if filename.startswith('~'): + # check for home directory + return os.path.expanduser(filename) + elif (ord(filename[0]) in (range(65,91)+range(99,123))) \ + and (filename[1:3] == ':\\'): + # check for absolute drive path (e.g. C:\...) + return filename + elif (filename.count('\\') >= 3) and (filename.startswith('\\\\')): + # check for absolute UNC path (e.g. \\server\...) + return filename + # return path with temp directory prepended + return os.path.expandvars(os.path.join('%TEMP%',filename)) + + +class FileCacheObject( CacheObject ): + _struct = struct.Struct('dII') # double and two ints + # timestamp, lifetime, position + + @classmethod + def fromFile(cls, fd): + dat = cls._struct.unpack(fd.read(cls._struct.size)) + obj = cls(None, None, dat[1], dat[0]) + obj.position = dat[2] + return obj + + def __init__(self, *args, **kwargs): + self._key = None + self._data = None + self._size = None + self._buff = StringIO() + super(FileCacheObject, self).__init__(*args, **kwargs) + + @property + def size(self): + if self._size is None: + self._buff.seek(0,2) + size = self._buff.tell() + if size == 0: + if (self._key is None) or (self._data is None): + raise RuntimeError + json.dump([self.key, self.data], self._buff) + self._size = self._buff.tell() + self._size = size + return self._size + @size.setter + def size(self, value): self._size = value + + @property + def key(self): + if self._key is None: + try: + self._key, self._data = json.loads(self._buff.getvalue()) + except: + pass + return self._key + @key.setter + def key(self, value): self._key = value + + @property + def data(self): + if self._data is None: + self._key, self._data = json.loads(self._buff.getvalue()) + return self._data + @data.setter + def data(self, value): self._data = value + + def load(self, fd): + fd.seek(self.position) + self._buff.seek(0) + self._buff.write(fd.read(self.size)) + + def dumpslot(self, fd): + pos = fd.tell() + fd.write(self._struct.pack(self.creation, self.lifetime, self.position)) + + def dumpdata(self, fd): + self.size + fd.seek(self.position) + fd.write(self._buff.getvalue()) + + +class FileEngine( CacheEngine ): + """Simple file-backed engine.""" + name = 'file' + _struct = struct.Struct('HH') # two shorts for version and count + _version = 2 + + def __init__(self, parent): + super(FileEngine, self).__init__(parent) + self.configure(None) + + def configure(self, filename, preallocate=256): + self.preallocate = preallocate + self.cachefile = filename + self.size = 0 + self.free = 0 + self.age = 0 + + def _init_cache(self): + # only run this once + self._init_cache = _donothing + + if self.cachefile is None: + raise TMDBCacheError("No cache filename given.") + + self.cachefile = parse_filename(self.cachefile) + + try: + # attempt to read existing cache at filename + # handle any errors that occur + self._open('r+b') + # seems to have read fine, make sure we have write access + if not os.access(self.cachefile, os.W_OK): + raise TMDBCacheWriteError(self.cachefile) + + except IOError as e: + if e.errno == errno.ENOENT: + # file does not exist, create a new one + try: + self._open('w+b') + self._write([]) + except IOError as e: + if e.errno == errno.ENOENT: + # directory does not exist + raise TMDBCacheDirectoryError(self.cachefile) + elif e.errno == errno.EACCES: + # user does not have rights to create new file + raise TMDBCacheWriteError(self.cachefile) + else: + # let the unhandled error continue through + raise + elif e.errno == errno.EACCESS: + # file exists, but we do not have permission to access it + raise TMDBCacheReadError(self.cachefile) + else: + # let the unhandled error continue through + raise + + def get(self, date): + self._init_cache() + self._open('r+b') + + with Flock(self.cachefd, Flock.LOCK_SH): # lock for shared access + # return any new objects in the cache + return self._read(date) + + def put(self, key, value, lifetime): + self._init_cache() + self._open('r+b') + + with Flock(self.cachefd, Flock.LOCK_EX): # lock for exclusive access + newobjs = self._read(self.age) + newobjs.append(FileCacheObject(key, value, lifetime)) + + # this will cause a new file object to be opened with the proper + # access mode, however the Flock should keep the old object open + # and properly locked + self._open('r+b') + self._write(newobjs) + return newobjs + + def _open(self, mode='r+b'): + # enforce binary operation + try: + if self.cachefd.mode == mode: + # already opened in requested mode, nothing to do + self.cachefd.seek(0) + return + except: pass # catch issue of no cachefile yet opened + self.cachefd = io.open(self.cachefile, mode) + + def _read(self, date): + try: + self.cachefd.seek(0) + version, count = self._struct.unpack(\ + self.cachefd.read(self._struct.size)) + if version != self._version: + # old version, break out and well rewrite when finished + raise Exception + + self.size = count + cache = [] + while count: + # loop through storage definitions + obj = FileCacheObject.fromFile(self.cachefd) + cache.append(obj) + count -= 1 + + except: + # failed to read information, so just discard it and return empty + self.size = 0 + self.free = 0 + return [] + + # get end of file + self.cachefd.seek(0,2) + position = self.cachefd.tell() + newobjs = [] + emptycount = 0 + + # walk backward through all, collecting new content and populating size + while len(cache): + obj = cache.pop() + if obj.creation == 0: + # unused slot, skip + emptycount += 1 + elif obj.expired: + # object has passed expiration date, no sense processing + continue + elif obj.creation > date: + # used slot with new data, process + obj.size, position = position - obj.position, obj.position + newobjs.append(obj) + # update age + self.age = max(self.age, obj.creation) + elif len(newobjs): + # end of new data, break + break + + # walk forward and load new content + for obj in newobjs: + obj.load(self.cachefd) + + self.free = emptycount + return newobjs + + def _write(self, data): + if self.free and (self.size != self.free): + # we only care about the last data point, since the rest are + # already stored in the file + data = data[-1] + + # determine write position of data in cache + self.cachefd.seek(0,2) + end = self.cachefd.tell() + data.position = end + + # write incremental update to free slot + self.cachefd.seek(4 + 16*(self.size-self.free)) + data.dumpslot(self.cachefd) + data.dumpdata(self.cachefd) + + else: + # rewrite cache file from scratch + # pull data from parent cache + data.extend(self.parent()._data.values()) + data.sort(key=lambda x: x.creation) + # write header + size = len(data) + self.preallocate + self.cachefd.seek(0) + self.cachefd.truncate() + self.cachefd.write(self._struct.pack(self._version, size)) + # write storage slot definitions + prev = None + for d in data: + if prev == None: + d.position = 4 + 16*size + else: + d.position = prev.position + prev.size + d.dumpslot(self.cachefd) + prev = d + # fill in allocated slots + for i in range(2**8): + self.cachefd.write(FileCacheObject._struct.pack(0, 0, 0)) + # write stored data + for d in data: + d.dumpdata(self.cachefd) + + self.cachefd.flush() + + def expire(self, key): + pass + + diff --git a/libs/tmdb3/cache_null.py b/libs/tmdb3/cache_null.py new file mode 100755 index 0000000..a59741c --- /dev/null +++ b/libs/tmdb3/cache_null.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +#----------------------- +# Name: cache_null.py +# Python Library +# Author: Raymond Wagner +# Purpose: Null caching engine for debugging purposes +#----------------------- + +from cache_engine import CacheEngine + +class NullEngine( CacheEngine ): + """Non-caching engine for debugging.""" + name = 'null' + def configure(self): pass + def get(self, date): return [] + def put(self, key, value, lifetime): return [] + def expire(self, key): pass + diff --git a/libs/tmdb3/locales.py b/libs/tmdb3/locales.py new file mode 100755 index 0000000..97efec7 --- /dev/null +++ b/libs/tmdb3/locales.py @@ -0,0 +1,634 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +#----------------------- +# Name: locales.py Stores locale information for filtering results +# Python Library +# Author: Raymond Wagner +#----------------------- + +from tmdb_exceptions import * +import locale + +syslocale = None + +class LocaleBase( object ): + __slots__ = ['__immutable'] + _stored = {} + fallthrough = False + + def __init__(self, *keys): + for key in keys: + self._stored[key.lower()] = self + self.__immutable = True + + def __setattr__(self, key, value): + if getattr(self, '__immutable', False): + raise NotImplementedError(self.__class__.__name__ + + ' does not support modification.') + super(LocaleBase, self).__setattr__(key, value) + + def __delattr__(self, key): + if getattr(self, '__immutable', False): + raise NotImplementedError(self.__class__.__name__ + + ' does not support modification.') + super(LocaleBase, self).__delattr__(key) + + def __lt__(self, other): + return (id(self) != id(other)) and (str(self) > str(other)) + def __gt__(self, other): + return (id(self) != id(other)) and (str(self) < str(other)) + def __eq__(self, other): + return (id(self) == id(other)) or (str(self) == str(other)) + + @classmethod + def getstored(cls, key): + if key is None: + return None + try: + return cls._stored[key.lower()] + except: + raise TMDBLocaleError("'{0}' is not a known valid {1} code."\ + .format(key, cls.__name__)) + +class Language( LocaleBase ): + __slots__ = ['ISO639_1', 'ISO639_2', 'ISO639_2B', 'englishname', + 'nativename'] + _stored = {} + + def __init__(self, iso1, iso2, ename): + self.ISO639_1 = iso1 + self.ISO639_2 = iso2 +# self.ISO639_2B = iso2b + self.englishname = ename +# self.nativename = nname + super(Language, self).__init__(iso1, iso2) + + def __str__(self): + return self.ISO639_1 + + def __repr__(self): + return u"".format(self) + +class Country( LocaleBase ): + __slots__ = ['alpha2', 'name'] + _stored = {} + + def __init__(self, alpha2, name): + self.alpha2 = alpha2 + self.name = name + super(Country, self).__init__(alpha2) + + def __str__(self): + return self.alpha2 + + def __repr__(self): + return u"".format(self) + +class Locale( LocaleBase ): + __slots__ = ['language', 'country', 'encoding'] + + def __init__(self, language, country, encoding): + self.language = Language.getstored(language) + self.country = Country.getstored(country) + self.encoding = encoding if encoding else 'latin-1' + + def __str__(self): + return u"{0}_{1}".format(self.language, self.country) + + def __repr__(self): + return u"".format(self) + + def encode(self, dat): + """Encode using system default encoding for network/file output.""" + try: + return dat.encode(self.encoding) + except AttributeError: + # not a string type, pass along + return dat + except UnicodeDecodeError: + # just return unmodified and hope for the best + return dat + + def decode(self, dat): + """Decode to system default encoding for internal use.""" + try: + return dat.decode(self.encoding) + except AttributeError: + # not a string type, pass along + return dat + except UnicodeEncodeError: + # just return unmodified and hope for the best + return dat + +def set_locale(language=None, country=None, fallthrough=False): + global syslocale + LocaleBase.fallthrough = fallthrough + + sysloc, sysenc = locale.getdefaultlocale() + + if (not language) or (not country): + dat = None + if syslocale is not None: + dat = (str(syslocale.language), str(syslocale.country)) + else: + if (sysloc is None) or ('_' not in sysloc): + dat = ('en', 'US') + else: + dat = sysloc.split('_') + if language is None: + language = dat[0] + if country is None: + country = dat[1] + + syslocale = Locale(language, country, sysenc) + +def get_locale(language=-1, country=-1): + """Output locale using provided attributes, or return system locale.""" + global syslocale + # pull existing stored values + if syslocale is None: + loc = Locale(None, None, locale.getdefaultlocale()[1]) + else: + loc = syslocale + + # both options are default, return stored values + if language == country == -1: + return loc + + # supplement default option with stored values + if language == -1: + language = loc.language + elif country == -1: + country = loc.country + return Locale(language, country, loc.encoding) + +######## AUTOGENERATED LANGUAGE AND COUNTRY DATA BELOW HERE ######### + +Language("ab", "abk", u"Abkhazian") +Language("aa", "aar", u"Afar") +Language("af", "afr", u"Afrikaans") +Language("ak", "aka", u"Akan") +Language("sq", "alb/sqi", u"Albanian") +Language("am", "amh", u"Amharic") +Language("ar", "ara", u"Arabic") +Language("an", "arg", u"Aragonese") +Language("hy", "arm/hye", u"Armenian") +Language("as", "asm", u"Assamese") +Language("av", "ava", u"Avaric") +Language("ae", "ave", u"Avestan") +Language("ay", "aym", u"Aymara") +Language("az", "aze", u"Azerbaijani") +Language("bm", "bam", u"Bambara") +Language("ba", "bak", u"Bashkir") +Language("eu", "baq/eus", u"Basque") +Language("be", "bel", u"Belarusian") +Language("bn", "ben", u"Bengali") +Language("bh", "bih", u"Bihari languages") +Language("bi", "bis", u"Bislama") +Language("nb", "nob", u"BokmÃ¥l, Norwegian") +Language("bs", "bos", u"Bosnian") +Language("br", "bre", u"Breton") +Language("bg", "bul", u"Bulgarian") +Language("my", "bur/mya", u"Burmese") +Language("es", "spa", u"Castilian") +Language("ca", "cat", u"Catalan") +Language("km", "khm", u"Central Khmer") +Language("ch", "cha", u"Chamorro") +Language("ce", "che", u"Chechen") +Language("ny", "nya", u"Chewa") +Language("ny", "nya", u"Chichewa") +Language("zh", "chi/zho", u"Chinese") +Language("za", "zha", u"Chuang") +Language("cu", "chu", u"Church Slavic") +Language("cu", "chu", u"Church Slavonic") +Language("cv", "chv", u"Chuvash") +Language("kw", "cor", u"Cornish") +Language("co", "cos", u"Corsican") +Language("cr", "cre", u"Cree") +Language("hr", "hrv", u"Croatian") +Language("cs", "cze/ces", u"Czech") +Language("da", "dan", u"Danish") +Language("dv", "div", u"Dhivehi") +Language("dv", "div", u"Divehi") +Language("nl", "dut/nld", u"Dutch") +Language("dz", "dzo", u"Dzongkha") +Language("en", "eng", u"English") +Language("eo", "epo", u"Esperanto") +Language("et", "est", u"Estonian") +Language("ee", "ewe", u"Ewe") +Language("fo", "fao", u"Faroese") +Language("fj", "fij", u"Fijian") +Language("fi", "fin", u"Finnish") +Language("nl", "dut/nld", u"Flemish") +Language("fr", "fre/fra", u"French") +Language("ff", "ful", u"Fulah") +Language("gd", "gla", u"Gaelic") +Language("gl", "glg", u"Galician") +Language("lg", "lug", u"Ganda") +Language("ka", "geo/kat", u"Georgian") +Language("de", "ger/deu", u"German") +Language("ki", "kik", u"Gikuyu") +Language("el", "gre/ell", u"Greek, Modern (1453-)") +Language("kl", "kal", u"Greenlandic") +Language("gn", "grn", u"Guarani") +Language("gu", "guj", u"Gujarati") +Language("ht", "hat", u"Haitian") +Language("ht", "hat", u"Haitian Creole") +Language("ha", "hau", u"Hausa") +Language("he", "heb", u"Hebrew") +Language("hz", "her", u"Herero") +Language("hi", "hin", u"Hindi") +Language("ho", "hmo", u"Hiri Motu") +Language("hu", "hun", u"Hungarian") +Language("is", "ice/isl", u"Icelandic") +Language("io", "ido", u"Ido") +Language("ig", "ibo", u"Igbo") +Language("id", "ind", u"Indonesian") +Language("ia", "ina", u"Interlingua (International Auxiliary Language Association)") +Language("ie", "ile", u"Interlingue") +Language("iu", "iku", u"Inuktitut") +Language("ik", "ipk", u"Inupiaq") +Language("ga", "gle", u"Irish") +Language("it", "ita", u"Italian") +Language("ja", "jpn", u"Japanese") +Language("jv", "jav", u"Javanese") +Language("kl", "kal", u"Kalaallisut") +Language("kn", "kan", u"Kannada") +Language("kr", "kau", u"Kanuri") +Language("ks", "kas", u"Kashmiri") +Language("kk", "kaz", u"Kazakh") +Language("ki", "kik", u"Kikuyu") +Language("rw", "kin", u"Kinyarwanda") +Language("ky", "kir", u"Kirghiz") +Language("kv", "kom", u"Komi") +Language("kg", "kon", u"Kongo") +Language("ko", "kor", u"Korean") +Language("kj", "kua", u"Kuanyama") +Language("ku", "kur", u"Kurdish") +Language("kj", "kua", u"Kwanyama") +Language("ky", "kir", u"Kyrgyz") +Language("lo", "lao", u"Lao") +Language("la", "lat", u"Latin") +Language("lv", "lav", u"Latvian") +Language("lb", "ltz", u"Letzeburgesch") +Language("li", "lim", u"Limburgan") +Language("li", "lim", u"Limburger") +Language("li", "lim", u"Limburgish") +Language("ln", "lin", u"Lingala") +Language("lt", "lit", u"Lithuanian") +Language("lu", "lub", u"Luba-Katanga") +Language("lb", "ltz", u"Luxembourgish") +Language("mk", "mac/mkd", u"Macedonian") +Language("mg", "mlg", u"Malagasy") +Language("ms", "may/msa", u"Malay") +Language("ml", "mal", u"Malayalam") +Language("dv", "div", u"Maldivian") +Language("mt", "mlt", u"Maltese") +Language("gv", "glv", u"Manx") +Language("mi", "mao/mri", u"Maori") +Language("mr", "mar", u"Marathi") +Language("mh", "mah", u"Marshallese") +Language("ro", "rum/ron", u"Moldavian") +Language("ro", "rum/ron", u"Moldovan") +Language("mn", "mon", u"Mongolian") +Language("na", "nau", u"Nauru") +Language("nv", "nav", u"Navaho") +Language("nv", "nav", u"Navajo") +Language("nd", "nde", u"Ndebele, North") +Language("nr", "nbl", u"Ndebele, South") +Language("ng", "ndo", u"Ndonga") +Language("ne", "nep", u"Nepali") +Language("nd", "nde", u"North Ndebele") +Language("se", "sme", u"Northern Sami") +Language("no", "nor", u"Norwegian") +Language("nb", "nob", u"Norwegian BokmÃ¥l") +Language("nn", "nno", u"Norwegian Nynorsk") +Language("ii", "iii", u"Nuosu") +Language("ny", "nya", u"Nyanja") +Language("nn", "nno", u"Nynorsk, Norwegian") +Language("ie", "ile", u"Occidental") +Language("oc", "oci", u"Occitan (post 1500)") +Language("oj", "oji", u"Ojibwa") +Language("cu", "chu", u"Old Bulgarian") +Language("cu", "chu", u"Old Church Slavonic") +Language("cu", "chu", u"Old Slavonic") +Language("or", "ori", u"Oriya") +Language("om", "orm", u"Oromo") +Language("os", "oss", u"Ossetian") +Language("os", "oss", u"Ossetic") +Language("pi", "pli", u"Pali") +Language("pa", "pan", u"Panjabi") +Language("ps", "pus", u"Pashto") +Language("fa", "per/fas", u"Persian") +Language("pl", "pol", u"Polish") +Language("pt", "por", u"Portuguese") +Language("pa", "pan", u"Punjabi") +Language("ps", "pus", u"Pushto") +Language("qu", "que", u"Quechua") +Language("ro", "rum/ron", u"Romanian") +Language("rm", "roh", u"Romansh") +Language("rn", "run", u"Rundi") +Language("ru", "rus", u"Russian") +Language("sm", "smo", u"Samoan") +Language("sg", "sag", u"Sango") +Language("sa", "san", u"Sanskrit") +Language("sc", "srd", u"Sardinian") +Language("gd", "gla", u"Scottish Gaelic") +Language("sr", "srp", u"Serbian") +Language("sn", "sna", u"Shona") +Language("ii", "iii", u"Sichuan Yi") +Language("sd", "snd", u"Sindhi") +Language("si", "sin", u"Sinhala") +Language("si", "sin", u"Sinhalese") +Language("sk", "slo/slk", u"Slovak") +Language("sl", "slv", u"Slovenian") +Language("so", "som", u"Somali") +Language("st", "sot", u"Sotho, Southern") +Language("nr", "nbl", u"South Ndebele") +Language("es", "spa", u"Spanish") +Language("su", "sun", u"Sundanese") +Language("sw", "swa", u"Swahili") +Language("ss", "ssw", u"Swati") +Language("sv", "swe", u"Swedish") +Language("tl", "tgl", u"Tagalog") +Language("ty", "tah", u"Tahitian") +Language("tg", "tgk", u"Tajik") +Language("ta", "tam", u"Tamil") +Language("tt", "tat", u"Tatar") +Language("te", "tel", u"Telugu") +Language("th", "tha", u"Thai") +Language("bo", "tib/bod", u"Tibetan") +Language("ti", "tir", u"Tigrinya") +Language("to", "ton", u"Tonga (Tonga Islands)") +Language("ts", "tso", u"Tsonga") +Language("tn", "tsn", u"Tswana") +Language("tr", "tur", u"Turkish") +Language("tk", "tuk", u"Turkmen") +Language("tw", "twi", u"Twi") +Language("ug", "uig", u"Uighur") +Language("uk", "ukr", u"Ukrainian") +Language("ur", "urd", u"Urdu") +Language("ug", "uig", u"Uyghur") +Language("uz", "uzb", u"Uzbek") +Language("ca", "cat", u"Valencian") +Language("ve", "ven", u"Venda") +Language("vi", "vie", u"Vietnamese") +Language("vo", "vol", u"Volapük") +Language("wa", "wln", u"Walloon") +Language("cy", "wel/cym", u"Welsh") +Language("fy", "fry", u"Western Frisian") +Language("wo", "wol", u"Wolof") +Language("xh", "xho", u"Xhosa") +Language("yi", "yid", u"Yiddish") +Language("yo", "yor", u"Yoruba") +Language("za", "zha", u"Zhuang") +Language("zu", "zul", u"Zulu") +Country("AF", u"AFGHANISTAN") +Country("AX", u"Ã…LAND ISLANDS") +Country("AL", u"ALBANIA") +Country("DZ", u"ALGERIA") +Country("AS", u"AMERICAN SAMOA") +Country("AD", u"ANDORRA") +Country("AO", u"ANGOLA") +Country("AI", u"ANGUILLA") +Country("AQ", u"ANTARCTICA") +Country("AG", u"ANTIGUA AND BARBUDA") +Country("AR", u"ARGENTINA") +Country("AM", u"ARMENIA") +Country("AW", u"ARUBA") +Country("AU", u"AUSTRALIA") +Country("AT", u"AUSTRIA") +Country("AZ", u"AZERBAIJAN") +Country("BS", u"BAHAMAS") +Country("BH", u"BAHRAIN") +Country("BD", u"BANGLADESH") +Country("BB", u"BARBADOS") +Country("BY", u"BELARUS") +Country("BE", u"BELGIUM") +Country("BZ", u"BELIZE") +Country("BJ", u"BENIN") +Country("BM", u"BERMUDA") +Country("BT", u"BHUTAN") +Country("BO", u"BOLIVIA, PLURINATIONAL STATE OF") +Country("BQ", u"BONAIRE, SINT EUSTATIUS AND SABA") +Country("BA", u"BOSNIA AND HERZEGOVINA") +Country("BW", u"BOTSWANA") +Country("BV", u"BOUVET ISLAND") +Country("BR", u"BRAZIL") +Country("IO", u"BRITISH INDIAN OCEAN TERRITORY") +Country("BN", u"BRUNEI DARUSSALAM") +Country("BG", u"BULGARIA") +Country("BF", u"BURKINA FASO") +Country("BI", u"BURUNDI") +Country("KH", u"CAMBODIA") +Country("CM", u"CAMEROON") +Country("CA", u"CANADA") +Country("CV", u"CAPE VERDE") +Country("KY", u"CAYMAN ISLANDS") +Country("CF", u"CENTRAL AFRICAN REPUBLIC") +Country("TD", u"CHAD") +Country("CL", u"CHILE") +Country("CN", u"CHINA") +Country("CX", u"CHRISTMAS ISLAND") +Country("CC", u"COCOS (KEELING) ISLANDS") +Country("CO", u"COLOMBIA") +Country("KM", u"COMOROS") +Country("CG", u"CONGO") +Country("CD", u"CONGO, THE DEMOCRATIC REPUBLIC OF THE") +Country("CK", u"COOK ISLANDS") +Country("CR", u"COSTA RICA") +Country("CI", u"CÔTE D'IVOIRE") +Country("HR", u"CROATIA") +Country("CU", u"CUBA") +Country("CW", u"CURAÇAO") +Country("CY", u"CYPRUS") +Country("CZ", u"CZECH REPUBLIC") +Country("DK", u"DENMARK") +Country("DJ", u"DJIBOUTI") +Country("DM", u"DOMINICA") +Country("DO", u"DOMINICAN REPUBLIC") +Country("EC", u"ECUADOR") +Country("EG", u"EGYPT") +Country("SV", u"EL SALVADOR") +Country("GQ", u"EQUATORIAL GUINEA") +Country("ER", u"ERITREA") +Country("EE", u"ESTONIA") +Country("ET", u"ETHIOPIA") +Country("FK", u"FALKLAND ISLANDS (MALVINAS)") +Country("FO", u"FAROE ISLANDS") +Country("FJ", u"FIJI") +Country("FI", u"FINLAND") +Country("FR", u"FRANCE") +Country("GF", u"FRENCH GUIANA") +Country("PF", u"FRENCH POLYNESIA") +Country("TF", u"FRENCH SOUTHERN TERRITORIES") +Country("GA", u"GABON") +Country("GM", u"GAMBIA") +Country("GE", u"GEORGIA") +Country("DE", u"GERMANY") +Country("GH", u"GHANA") +Country("GI", u"GIBRALTAR") +Country("GR", u"GREECE") +Country("GL", u"GREENLAND") +Country("GD", u"GRENADA") +Country("GP", u"GUADELOUPE") +Country("GU", u"GUAM") +Country("GT", u"GUATEMALA") +Country("GG", u"GUERNSEY") +Country("GN", u"GUINEA") +Country("GW", u"GUINEA-BISSAU") +Country("GY", u"GUYANA") +Country("HT", u"HAITI") +Country("HM", u"HEARD ISLAND AND MCDONALD ISLANDS") +Country("VA", u"HOLY SEE (VATICAN CITY STATE)") +Country("HN", u"HONDURAS") +Country("HK", u"HONG KONG") +Country("HU", u"HUNGARY") +Country("IS", u"ICELAND") +Country("IN", u"INDIA") +Country("ID", u"INDONESIA") +Country("IR", u"IRAN, ISLAMIC REPUBLIC OF") +Country("IQ", u"IRAQ") +Country("IE", u"IRELAND") +Country("IM", u"ISLE OF MAN") +Country("IL", u"ISRAEL") +Country("IT", u"ITALY") +Country("JM", u"JAMAICA") +Country("JP", u"JAPAN") +Country("JE", u"JERSEY") +Country("JO", u"JORDAN") +Country("KZ", u"KAZAKHSTAN") +Country("KE", u"KENYA") +Country("KI", u"KIRIBATI") +Country("KP", u"KOREA, DEMOCRATIC PEOPLE'S REPUBLIC OF") +Country("KR", u"KOREA, REPUBLIC OF") +Country("KW", u"KUWAIT") +Country("KG", u"KYRGYZSTAN") +Country("LA", u"LAO PEOPLE'S DEMOCRATIC REPUBLIC") +Country("LV", u"LATVIA") +Country("LB", u"LEBANON") +Country("LS", u"LESOTHO") +Country("LR", u"LIBERIA") +Country("LY", u"LIBYA") +Country("LI", u"LIECHTENSTEIN") +Country("LT", u"LITHUANIA") +Country("LU", u"LUXEMBOURG") +Country("MO", u"MACAO") +Country("MK", u"MACEDONIA, THE FORMER YUGOSLAV REPUBLIC OF") +Country("MG", u"MADAGASCAR") +Country("MW", u"MALAWI") +Country("MY", u"MALAYSIA") +Country("MV", u"MALDIVES") +Country("ML", u"MALI") +Country("MT", u"MALTA") +Country("MH", u"MARSHALL ISLANDS") +Country("MQ", u"MARTINIQUE") +Country("MR", u"MAURITANIA") +Country("MU", u"MAURITIUS") +Country("YT", u"MAYOTTE") +Country("MX", u"MEXICO") +Country("FM", u"MICRONESIA, FEDERATED STATES OF") +Country("MD", u"MOLDOVA, REPUBLIC OF") +Country("MC", u"MONACO") +Country("MN", u"MONGOLIA") +Country("ME", u"MONTENEGRO") +Country("MS", u"MONTSERRAT") +Country("MA", u"MOROCCO") +Country("MZ", u"MOZAMBIQUE") +Country("MM", u"MYANMAR") +Country("NA", u"NAMIBIA") +Country("NR", u"NAURU") +Country("NP", u"NEPAL") +Country("NL", u"NETHERLANDS") +Country("NC", u"NEW CALEDONIA") +Country("NZ", u"NEW ZEALAND") +Country("NI", u"NICARAGUA") +Country("NE", u"NIGER") +Country("NG", u"NIGERIA") +Country("NU", u"NIUE") +Country("NF", u"NORFOLK ISLAND") +Country("MP", u"NORTHERN MARIANA ISLANDS") +Country("NO", u"NORWAY") +Country("OM", u"OMAN") +Country("PK", u"PAKISTAN") +Country("PW", u"PALAU") +Country("PS", u"PALESTINIAN TERRITORY, OCCUPIED") +Country("PA", u"PANAMA") +Country("PG", u"PAPUA NEW GUINEA") +Country("PY", u"PARAGUAY") +Country("PE", u"PERU") +Country("PH", u"PHILIPPINES") +Country("PN", u"PITCAIRN") +Country("PL", u"POLAND") +Country("PT", u"PORTUGAL") +Country("PR", u"PUERTO RICO") +Country("QA", u"QATAR") +Country("RE", u"RÉUNION") +Country("RO", u"ROMANIA") +Country("RU", u"RUSSIAN FEDERATION") +Country("RW", u"RWANDA") +Country("BL", u"SAINT BARTHÉLEMY") +Country("SH", u"SAINT HELENA, ASCENSION AND TRISTAN DA CUNHA") +Country("KN", u"SAINT KITTS AND NEVIS") +Country("LC", u"SAINT LUCIA") +Country("MF", u"SAINT MARTIN (FRENCH PART)") +Country("PM", u"SAINT PIERRE AND MIQUELON") +Country("VC", u"SAINT VINCENT AND THE GRENADINES") +Country("WS", u"SAMOA") +Country("SM", u"SAN MARINO") +Country("ST", u"SAO TOME AND PRINCIPE") +Country("SA", u"SAUDI ARABIA") +Country("SN", u"SENEGAL") +Country("RS", u"SERBIA") +Country("SC", u"SEYCHELLES") +Country("SL", u"SIERRA LEONE") +Country("SG", u"SINGAPORE") +Country("SX", u"SINT MAARTEN (DUTCH PART)") +Country("SK", u"SLOVAKIA") +Country("SI", u"SLOVENIA") +Country("SB", u"SOLOMON ISLANDS") +Country("SO", u"SOMALIA") +Country("ZA", u"SOUTH AFRICA") +Country("GS", u"SOUTH GEORGIA AND THE SOUTH SANDWICH ISLANDS") +Country("SS", u"SOUTH SUDAN") +Country("ES", u"SPAIN") +Country("LK", u"SRI LANKA") +Country("SD", u"SUDAN") +Country("SR", u"SURINAME") +Country("SJ", u"SVALBARD AND JAN MAYEN") +Country("SZ", u"SWAZILAND") +Country("SE", u"SWEDEN") +Country("CH", u"SWITZERLAND") +Country("SY", u"SYRIAN ARAB REPUBLIC") +Country("TW", u"TAIWAN, PROVINCE OF CHINA") +Country("TJ", u"TAJIKISTAN") +Country("TZ", u"TANZANIA, UNITED REPUBLIC OF") +Country("TH", u"THAILAND") +Country("TL", u"TIMOR-LESTE") +Country("TG", u"TOGO") +Country("TK", u"TOKELAU") +Country("TO", u"TONGA") +Country("TT", u"TRINIDAD AND TOBAGO") +Country("TN", u"TUNISIA") +Country("TR", u"TURKEY") +Country("TM", u"TURKMENISTAN") +Country("TC", u"TURKS AND CAICOS ISLANDS") +Country("TV", u"TUVALU") +Country("UG", u"UGANDA") +Country("UA", u"UKRAINE") +Country("AE", u"UNITED ARAB EMIRATES") +Country("GB", u"UNITED KINGDOM") +Country("US", u"UNITED STATES") +Country("UM", u"UNITED STATES MINOR OUTLYING ISLANDS") +Country("UY", u"URUGUAY") +Country("UZ", u"UZBEKISTAN") +Country("VU", u"VANUATU") +Country("VE", u"VENEZUELA, BOLIVARIAN REPUBLIC OF") +Country("VN", u"VIET NAM") +Country("VG", u"VIRGIN ISLANDS, BRITISH") +Country("VI", u"VIRGIN ISLANDS, U.S.") +Country("WF", u"WALLIS AND FUTUNA") +Country("EH", u"WESTERN SAHARA") +Country("YE", u"YEMEN") +Country("ZM", u"ZAMBIA") +Country("ZW", u"ZIMBABWE") diff --git a/libs/tmdb3/pager.py b/libs/tmdb3/pager.py new file mode 100755 index 0000000..6cb874c --- /dev/null +++ b/libs/tmdb3/pager.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +#----------------------- +# Name: pager.py List-like structure designed for handling paged results +# Python Library +# Author: Raymond Wagner +#----------------------- + +from collections import Sequence, Iterator + +class PagedIterator( Iterator ): + def __init__(self, parent): + self._parent = parent + self._index = -1 + self._len = len(parent) + + def __iter__(self): + return self + + def next(self): + self._index += 1 + if self._index == self._len: + raise StopIteration + return self._parent[self._index] + +class UnpagedData( object ): + def copy(self): + return self.__class__() + + def __mul__(self, other): + return (self.copy() for a in range(other)) + + def __rmul__(self, other): + return (self.copy() for a in range(other)) + +class PagedList( Sequence ): + """ + List-like object, with support for automatically grabbing additional + pages from a data source. + """ + _iter_class = None + + def __iter__(self): + if self._iter_class is None: + self._iter_class = type(self.__class__.__name__ + 'Iterator', + (PagedIterator,), {}) + return self._iter_class(self) + + def __len__(self): + try: + return self._len + except: + return len(self._data) + + def __init__(self, iterable, pagesize=20): + self._data = list(iterable) + self._pagesize = pagesize + + def __getitem__(self, index): + if isinstance(index, slice): + return [self[x] for x in xrange(*index.indices(len(self)))] + if index >= len(self): + raise IndexError("list index outside range") + if (index >= len(self._data)) \ + or isinstance(self._data[index], UnpagedData): + self._populatepage(index/self._pagesize + 1) + return self._data[index] + + def __setitem__(self, index, value): + raise NotImplementedError + + def __delitem__(self, index): + raise NotImplementedError + + def __contains__(self, item): + raise NotImplementedError + + def _populatepage(self, page): + pagestart = (page-1) * self._pagesize + if len(self._data) < pagestart: + self._data.extend(UnpagedData()*(pagestart-len(self._data))) + if len(self._data) == pagestart: + self._data.extend(self._getpage(page)) + else: + for data in self._getpage(page): + self._data[pagestart] = data + pagestart += 1 + + def _getpage(self, page): + raise NotImplementedError("PagedList._getpage() must be provided "+\ + "by subclass") + +class PagedRequest( PagedList ): + """ + Derived PageList that provides a list-like object with automatic paging + intended for use with search requests. + """ + def __init__(self, request, handler=None): + self._request = request + if handler: self._handler = handler + super(PagedRequest, self).__init__(self._getpage(1), 20) + + def _getpage(self, page): + req = self._request.new(page=page) + res = req.readJSON() + self._len = res['total_results'] + for item in res['results']: + yield self._handler(item) + diff --git a/libs/tmdb3/request.py b/libs/tmdb3/request.py new file mode 100755 index 0000000..109630d --- /dev/null +++ b/libs/tmdb3/request.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +#----------------------- +# Name: tmdb_request.py +# Python Library +# Author: Raymond Wagner +# Purpose: Wrapped urllib2.Request class pre-configured for accessing the +# TMDb v3 API +#----------------------- + +from tmdb_exceptions import * +from locales import get_locale +from cache import Cache + +from urllib import urlencode +import urllib2 +import json + +DEBUG = False +cache = Cache(filename='pytmdb3.cache') + +#DEBUG = True +#cache = Cache(engine='null') + +def set_key(key): + """ + Specify the API key to use retrieving data from themoviedb.org. This + key must be set before any calls will function. + """ + if len(key) != 32: + raise TMDBKeyInvalid("Specified API key must be 128-bit hex") + try: + int(key, 16) + except: + raise TMDBKeyInvalid("Specified API key must be 128-bit hex") + Request._api_key = key + +def set_cache(engine=None, *args, **kwargs): + """Specify caching engine and properties.""" + cache.configure(engine, *args, **kwargs) + +class Request( urllib2.Request ): + _api_key = None + _base_url = "http://api.themoviedb.org/3/" + + @property + def api_key(self): + if self._api_key is None: + raise TMDBKeyMissing("API key must be specified before "+\ + "requests can be made") + return self._api_key + + def __init__(self, url, **kwargs): + """Return a request object, using specified API path and arguments.""" + kwargs['api_key'] = self.api_key + self._url = url.lstrip('/') + self._kwargs = dict([(kwa,kwv) for kwa,kwv in kwargs.items() + if kwv is not None]) + + locale = get_locale() + kwargs = {} + for k,v in self._kwargs.items(): + kwargs[k] = locale.encode(v) + url = '{0}{1}?{2}'.format(self._base_url, self._url, urlencode(kwargs)) + + urllib2.Request.__init__(self, url) + self.add_header('Accept', 'application/json') + self.lifetime = 3600 # 1hr + + def new(self, **kwargs): + """Create a new instance of the request, with tweaked arguments.""" + args = dict(self._kwargs) + for k,v in kwargs.items(): + if v is None: + if k in args: + del args[k] + else: + args[k] = v + obj = self.__class__(self._url, **args) + obj.lifetime = self.lifetime + return obj + + def add_data(self, data): + """Provide data to be sent with POST.""" + urllib2.Request.add_data(self, urlencode(data)) + + def open(self): + """Open a file object to the specified URL.""" + try: + if DEBUG: + print 'loading '+self.get_full_url() + if self.has_data(): + print ' '+self.get_data() + return urllib2.urlopen(self) + except urllib2.HTTPError, e: + raise TMDBHTTPError(e) + + def read(self): + """Return result from specified URL as a string.""" + return self.open().read() + + @cache.cached(urllib2.Request.get_full_url) + def readJSON(self): + """Parse result from specified URL as JSON data.""" + url = self.get_full_url() + try: + # catch HTTP error from open() + data = json.load(self.open()) + except TMDBHTTPError, e: + try: + # try to load whatever was returned + data = json.loads(e.response) + except: + # cannot parse json, just raise existing error + raise e + else: + # response parsed, try to raise error from TMDB + handle_status(data, url) + # no error from TMDB, just raise existing error + raise e + handle_status(data, url) + #if DEBUG: + # import pprint + # pprint.PrettyPrinter().pprint(data) + return data + +status_handlers = { + 1: None, + 2: TMDBRequestInvalid('Invalid service - This service does not exist.'), + 3: TMDBRequestError('Authentication Failed - You do not have '+\ + 'permissions to access this service.'), + 4: TMDBRequestInvalid("Invalid format - This service doesn't exist "+\ + 'in that format.'), + 5: TMDBRequestInvalid('Invalid parameters - Your request parameters '+\ + 'are incorrect.'), + 6: TMDBRequestInvalid('Invalid id - The pre-requisite id is invalid '+\ + 'or not found.'), + 7: TMDBKeyInvalid('Invalid API key - You must be granted a valid key.'), + 8: TMDBRequestError('Duplicate entry - The data you tried to submit '+\ + 'already exists.'), + 9: TMDBOffline('This service is tempirarily offline. Try again later.'), + 10: TMDBKeyRevoked('Suspended API key - Access to your account has been '+\ + 'suspended, contact TMDB.'), + 11: TMDBError('Internal error - Something went wrong. Contact TMDb.'), + 12: None, + 13: None, + 14: TMDBRequestError('Authentication Failed.'), + 15: TMDBError('Failed'), + 16: TMDBError('Device Denied'), + 17: TMDBError('Session Denied')} + +def handle_status(data, query): + status = status_handlers[data.get('status_code', 1)] + if status is not None: + status.tmdberrno = data['status_code'] + status.query = query + raise status diff --git a/libs/tmdb3/tmdb_api.py b/libs/tmdb3/tmdb_api.py new file mode 100755 index 0000000..b5cb0a9 --- /dev/null +++ b/libs/tmdb3/tmdb_api.py @@ -0,0 +1,689 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +#----------------------- +# Name: tmdb_api.py Simple-to-use Python interface to TMDB's API v3 +# Python Library +# Author: Raymond Wagner +# Purpose: This Python library is intended to provide a series of classes +# and methods for search and retrieval of text metadata and image +# URLs from TMDB. +# Preliminary API specifications can be found at +# http://help.themoviedb.org/kb/api/about-3 +# License: Creative Commons GNU GPL v2 +# (http://creativecommons.org/licenses/GPL/2.0/) +#----------------------- + +__title__ = "tmdb_api - Simple-to-use Python interface to TMDB's API v3 "+\ + "(www.themoviedb.org)" +__author__ = "Raymond Wagner" +__purpose__ = """ +This Python library is intended to provide a series of classes and methods +for search and retrieval of text metadata and image URLs from TMDB. +Preliminary API specifications can be found at +http://help.themoviedb.org/kb/api/about-3""" + +__version__="v0.6.17" +# 0.1.0 Initial development +# 0.2.0 Add caching mechanism for API queries +# 0.2.1 Temporary work around for broken search paging +# 0.3.0 Rework backend machinery for managing OO interface to results +# 0.3.1 Add collection support +# 0.3.2 Remove MythTV key from results.py +# 0.3.3 Add functional language support +# 0.3.4 Re-enable search paging +# 0.3.5 Add methods for grabbing current, popular, and top rated movies +# 0.3.6 Rework paging mechanism +# 0.3.7 Generalize caching mechanism, and allow controllability +# 0.4.0 Add full locale support (language and country) and optional fall through +# 0.4.1 Add custom classmethod for dealing with IMDB movie IDs +# 0.4.2 Improve cache file selection for Windows systems +# 0.4.3 Add a few missed Person properties +# 0.4.4 Add support for additional Studio information +# 0.4.5 Add locale fallthrough for images and alternate titles +# 0.4.6 Add slice support for search results +# 0.5.0 Rework cache framework and improve file cache performance +# 0.6.0 Add user authentication support +# 0.6.1 Add adult filtering for people searches +# 0.6.2 Add similar movie search for Movie objects +# 0.6.3 Add Studio search +# 0.6.4 Add Genre list and associated Movie search +# 0.6.5 Prevent data from being blanked out by subsequent queries +# 0.6.6 Turn date processing errors into mutable warnings +# 0.6.7 Add support for searching by year +# 0.6.8 Add support for collection images +# 0.6.9 Correct Movie image language filtering +# 0.6.10 Add upcoming movie classmethod +# 0.6.11 Fix URL for top rated Movie query +# 0.6.12 Add support for Movie watchlist query and editing +# 0.6.13 Fix URL for rating Movies +# 0.6.14 Add support for Lists +# 0.6.15 Add ability to search Collections +# 0.6.16 Make absent primary images return None (previously u'') +# 0.6.17 Add userrating/votes to Image, add overview to Collection, remove +# releasedate sorting from Collection Movies + +from request import set_key, Request +from util import Datapoint, Datalist, Datadict, Element, NameRepr, SearchRepr +from pager import PagedRequest +from locales import get_locale, set_locale +from tmdb_auth import get_session, set_session +from tmdb_exceptions import * + +import datetime + +DEBUG = False + +def process_date(datestr): + try: + return datetime.date(*[int(x) for x in datestr.split('-')]) + except (TypeError, ValueError): + import sys + import warnings + import traceback + _,_,tb = sys.exc_info() + f,l,_,_ = traceback.extract_tb(tb)[-1] + warnings.warn_explicit(('"{0}" is not a supported date format. ' + 'Please fix upstream data at http://www.themoviedb.org.')\ + .format(datestr), Warning, f, l) + return None + +class Configuration( Element ): + images = Datapoint('images') + def _populate(self): + return Request('configuration') +Configuration = Configuration() + +class Account( NameRepr, Element ): + def _populate(self): + return Request('account', session_id=self._session.sessionid) + + id = Datapoint('id') + adult = Datapoint('include_adult') + country = Datapoint('iso_3166_1') + language = Datapoint('iso_639_1') + name = Datapoint('name') + username = Datapoint('username') + + @property + def locale(self): + return get_locale(self.language, self.country) + +def searchMovie(query, locale=None, adult=False, year=None): + kwargs = {'query':query, 'include_adult':adult} + if year is not None: + try: + kwargs['year'] = year.year + except AttributeError: + kwargs['year'] = year + return MovieSearchResult(Request('search/movie', **kwargs), locale=locale) + +def searchMovieWithYear(query, locale=None, adult=False): + year = None + if (len(query) > 6) and (query[-1] == ')') and (query[-6] == '('): + # simple syntax check, no need for regular expression + try: + year = int(query[-5:-1]) + except ValueError: + pass + else: + if 1885 < year < 2050: + # strip out year from search + query = query[:-7] + else: + # sanity check on resolved year failed, pass through + year = None + return searchMovie(query, locale, adult, year) + +class MovieSearchResult( SearchRepr, PagedRequest ): + """Stores a list of search matches.""" + _name = None + def __init__(self, request, locale=None): + if locale is None: + locale = get_locale() + super(MovieSearchResult, self).__init__( + request.new(language=locale.language), + lambda x: Movie(raw=x, locale=locale)) + +def searchPerson(query, adult=False): + return PeopleSearchResult(Request('search/person', query=query, + include_adult=adult)) + +class PeopleSearchResult( SearchRepr, PagedRequest ): + """Stores a list of search matches.""" + _name = None + def __init__(self, request): + super(PeopleSearchResult, self).__init__(request, + lambda x: Person(raw=x)) + +def searchStudio(query): + return StudioSearchResult(Request('search/company', query=query)) + +class StudioSearchResult( SearchRepr, PagedRequest ): + """Stores a list of search matches.""" + _name = None + def __init__(self, request): + super(StudioSearchResult, self).__init__(request, + lambda x: Studio(raw=x)) + +def searchList(query, adult=False): + ListSearchResult(Request('search/list', query=query, include_adult=adult)) + +class ListSearchResult( SearchRepr, PagedRequest ): + """Stores a list of search matches.""" + _name = None + def __init__(self, request): + super(ListSearchResult, self).__init__(request, + lambda x: List(raw=x)) + +def searchCollection(query, locale=None): + return CollectionSearchResult(Request('search/collection', query=query), + locale=locale) + +class CollectionSearchResult( SearchRepr, PagedRequest ): + """Stores a list of search matches.""" + _name=None + def __init__(self, request, locale=None): + if locale is None: + locale = get_locale() + super(CollectionSearchResult, self).__init__( + request.new(language=locale.language), + lambda x: Collection(raw=x, locale=locale)) + +class Image( Element ): + filename = Datapoint('file_path', initarg=1, + handler=lambda x: x.lstrip('/')) + aspectratio = Datapoint('aspect_ratio') + height = Datapoint('height') + width = Datapoint('width') + language = Datapoint('iso_639_1') + userrating = Datapoint('vote_average') + votes = Datapoint('vote_count') + + def sizes(self): + return ['original'] + + def geturl(self, size='original'): + if size not in self.sizes(): + raise TMDBImageSizeError + url = Configuration.images['base_url'].rstrip('/') + return url+'/{0}/{1}'.format(size, self.filename) + + # sort preferring locale's language, but keep remaining ordering consistent + def __lt__(self, other): + return (self.language == self._locale.language) \ + and (self.language != other.language) + def __gt__(self, other): + return (self.language != other.language) \ + and (other.language == self._locale.language) + # direct match for comparison + def __eq__(self, other): + return self.filename == other.filename + # special handling for boolean to see if exists + def __nonzero__(self): + if len(self.filename) == 0: + return False + return True + + def __repr__(self): + # BASE62 encoded filename, no need to worry about unicode + return u"<{0.__class__.__name__} '{0.filename}'>".format(self) + +class Backdrop( Image ): + def sizes(self): + return Configuration.images['backdrop_sizes'] +class Poster( Image ): + def sizes(self): + return Configuration.images['poster_sizes'] +class Profile( Image ): + def sizes(self): + return Configuration.images['profile_sizes'] +class Logo( Image ): + def sizes(self): + return Configuration.images['logo_sizes'] + +class AlternateTitle( Element ): + country = Datapoint('iso_3166_1') + title = Datapoint('title') + + # sort preferring locale's country, but keep remaining ordering consistent + def __lt__(self, other): + return (self.country == self._locale.country) \ + and (self.country != other.country) + def __gt__(self, other): + return (self.country != other.country) \ + and (other.country == self._locale.country) + def __eq__(self, other): + return self.country == other.country + + def __repr__(self): + return u"<{0.__class__.__name__} '{0.title}' ({0.country})>"\ + .format(self).encode('utf-8') + +class Person( Element ): + id = Datapoint('id', initarg=1) + name = Datapoint('name') + biography = Datapoint('biography') + dayofbirth = Datapoint('birthday', default=None, handler=process_date) + dayofdeath = Datapoint('deathday', default=None, handler=process_date) + homepage = Datapoint('homepage') + birthplace = Datapoint('place_of_birth') + profile = Datapoint('profile_path', handler=Profile, \ + raw=False, default=None) + adult = Datapoint('adult') + aliases = Datalist('also_known_as') + + def __repr__(self): + return u"<{0.__class__.__name__} '{0.name}'>"\ + .format(self).encode('utf-8') + + def _populate(self): + return Request('person/{0}'.format(self.id)) + def _populate_credits(self): + return Request('person/{0}/credits'.format(self.id), \ + language=self._locale.language) + def _populate_images(self): + return Request('person/{0}/images'.format(self.id)) + + roles = Datalist('cast', handler=lambda x: ReverseCast(raw=x), \ + poller=_populate_credits) + crew = Datalist('crew', handler=lambda x: ReverseCrew(raw=x), \ + poller=_populate_credits) + profiles = Datalist('profiles', handler=Profile, poller=_populate_images) + +class Cast( Person ): + character = Datapoint('character') + order = Datapoint('order') + + def __repr__(self): + return u"<{0.__class__.__name__} '{0.name}' as '{0.character}'>"\ + .format(self).encode('utf-8') + +class Crew( Person ): + job = Datapoint('job') + department = Datapoint('department') + + def __repr__(self): + return u"<{0.__class__.__name__} '{0.name}','{0.job}'>"\ + .format(self).encode('utf-8') + +class Keyword( Element ): + id = Datapoint('id') + name = Datapoint('name') + + def __repr__(self): + return u"<{0.__class__.__name__} {0.name}>".format(self).encode('utf-8') + +class Release( Element ): + certification = Datapoint('certification') + country = Datapoint('iso_3166_1') + releasedate = Datapoint('release_date', handler=process_date) + def __repr__(self): + return u"<{0.__class__.__name__} {0.country}, {0.releasedate}>"\ + .format(self).encode('utf-8') + +class Trailer( Element ): + name = Datapoint('name') + size = Datapoint('size') + source = Datapoint('source') + +class YoutubeTrailer( Trailer ): + def geturl(self): + return "http://www.youtube.com/watch?v={0}".format(self.source) + + def __repr__(self): + # modified BASE64 encoding, no need to worry about unicode + return u"<{0.__class__.__name__} '{0.name}'>".format(self) + +class AppleTrailer( Element ): + name = Datapoint('name') + sources = Datadict('sources', handler=Trailer, attr='size') + + def sizes(self): + return self.sources.keys() + + def geturl(self, size=None): + if size is None: + # sort assuming ###p format for now, take largest resolution + size = str(sorted([int(size[:-1]) for size in self.sources])[-1])+'p' + return self.sources[size].source + + def __repr__(self): + return u"<{0.__class__.__name__} '{0.name}'>".format(self) + +class Translation( Element ): + name = Datapoint('name') + language = Datapoint('iso_639_1') + englishname = Datapoint('english_name') + + def __repr__(self): + return u"<{0.__class__.__name__} '{0.name}' ({0.language})>"\ + .format(self).encode('utf-8') + +class Genre( NameRepr, Element ): + id = Datapoint('id') + name = Datapoint('name') + + def _populate_movies(self): + return Request('genre/{0}/movies'.format(self.id), \ + language=self._locale.language) + + @property + def movies(self): + if 'movies' not in self._data: + search = MovieSearchResult(self._populate_movies(), \ + locale=self._locale) + search._name = "{0.name} Movies".format(self) + self._data['movies'] = search + return self._data['movies'] + + @classmethod + def getAll(cls, locale=None): + class GenreList( Element ): + genres = Datalist('genres', handler=Genre) + def _populate(self): + return Request('genre/list', language=self._locale.language) + return GenreList(locale=locale).genres + + +class Studio( NameRepr, Element ): + id = Datapoint('id', initarg=1) + name = Datapoint('name') + description = Datapoint('description') + headquarters = Datapoint('headquarters') + logo = Datapoint('logo_path', handler=Logo, \ + raw=False, default=None) + # FIXME: manage not-yet-defined handlers in a way that will propogate + # locale information properly + parent = Datapoint('parent_company', \ + handler=lambda x: Studio(raw=x)) + + def _populate(self): + return Request('company/{0}'.format(self.id)) + def _populate_movies(self): + return Request('company/{0}/movies'.format(self.id), \ + language=self._locale.language) + + # FIXME: add a cleaner way of adding types with no additional processing + @property + def movies(self): + if 'movies' not in self._data: + search = MovieSearchResult(self._populate_movies(), \ + locale=self._locale) + search._name = "{0.name} Movies".format(self) + self._data['movies'] = search + return self._data['movies'] + +class Country( NameRepr, Element ): + code = Datapoint('iso_3166_1') + name = Datapoint('name') + +class Language( NameRepr, Element ): + code = Datapoint('iso_639_1') + name = Datapoint('name') + +class Movie( Element ): + @classmethod + def latest(cls): + req = Request('latest/movie') + req.lifetime = 600 + return cls(raw=req.readJSON()) + + @classmethod + def nowplaying(cls, locale=None): + res = MovieSearchResult(Request('movie/now-playing'), locale=locale) + res._name = 'Now Playing' + return res + + @classmethod + def mostpopular(cls, locale=None): + res = MovieSearchResult(Request('movie/popular'), locale=locale) + res._name = 'Popular' + return res + + @classmethod + def toprated(cls, locale=None): + res = MovieSearchResult(Request('movie/top_rated'), locale=locale) + res._name = 'Top Rated' + return res + + @classmethod + def upcoming(cls, locale=None): + res = MovieSearchResult(Request('movie/upcoming'), locale=locale) + res._name = 'Upcoming' + return res + + @classmethod + def favorites(cls, session=None): + if session is None: + session = get_session() + account = Account(session=session) + res = MovieSearchResult( + Request('account/{0}/favorite_movies'.format(account.id), + session_id=session.sessionid)) + res._name = "Favorites" + return res + + @classmethod + def ratedmovies(cls, session=None): + if session is None: + session = get_session() + account = Account(session=session) + res = MovieSearchResult( + Request('account/{0}/rated_movies'.format(account.id), + session_id=session.sessionid)) + res._name = "Movies You Rated" + return res + + @classmethod + def watchlist(cls, session=None): + if session is None: + session = get_session() + account = Account(session=session) + res = MovieSearchResult( + Request('account/{0}/movie_watchlist'.format(account.id), + session_id=session.sessionid)) + res._name = "Movies You're Watching" + return res + + @classmethod + def fromIMDB(cls, imdbid, locale=None): + try: + # assume string + if not imdbid.startswith('tt'): + imdbid = "tt{0:0>7}".format(imdbid) + except AttributeError: + # assume integer + imdbid = "tt{0:0>7}".format(imdbid) + if locale is None: + locale = get_locale() + movie = cls(imdbid, locale=locale) + movie._populate() + return movie + + id = Datapoint('id', initarg=1) + title = Datapoint('title') + originaltitle = Datapoint('original_title') + tagline = Datapoint('tagline') + overview = Datapoint('overview') + runtime = Datapoint('runtime') + budget = Datapoint('budget') + revenue = Datapoint('revenue') + releasedate = Datapoint('release_date', handler=process_date) + homepage = Datapoint('homepage') + imdb = Datapoint('imdb_id') + + backdrop = Datapoint('backdrop_path', handler=Backdrop, \ + raw=False, default=None) + poster = Datapoint('poster_path', handler=Poster, \ + raw=False, default=None) + + popularity = Datapoint('popularity') + userrating = Datapoint('vote_average') + votes = Datapoint('vote_count') + + adult = Datapoint('adult') + collection = Datapoint('belongs_to_collection', handler=lambda x: \ + Collection(raw=x)) + genres = Datalist('genres', handler=Genre) + studios = Datalist('production_companies', handler=Studio) + countries = Datalist('production_countries', handler=Country) + languages = Datalist('spoken_languages', handler=Language) + + def _populate(self): + return Request('movie/{0}'.format(self.id), \ + language=self._locale.language) + def _populate_titles(self): + kwargs = {} + if not self._locale.fallthrough: + kwargs['country'] = self._locale.country + return Request('movie/{0}/alternative_titles'.format(self.id), **kwargs) + def _populate_cast(self): + return Request('movie/{0}/casts'.format(self.id)) + def _populate_images(self): + kwargs = {} + if not self._locale.fallthrough: + kwargs['language'] = self._locale.language + return Request('movie/{0}/images'.format(self.id), **kwargs) + def _populate_keywords(self): + return Request('movie/{0}/keywords'.format(self.id)) + def _populate_releases(self): + return Request('movie/{0}/releases'.format(self.id)) + def _populate_trailers(self): + return Request('movie/{0}/trailers'.format(self.id), \ + language=self._locale.language) + def _populate_translations(self): + return Request('movie/{0}/translations'.format(self.id)) + + alternate_titles = Datalist('titles', handler=AlternateTitle, \ + poller=_populate_titles, sort=True) + cast = Datalist('cast', handler=Cast, \ + poller=_populate_cast, sort='order') + crew = Datalist('crew', handler=Crew, poller=_populate_cast) + backdrops = Datalist('backdrops', handler=Backdrop, \ + poller=_populate_images, sort=True) + posters = Datalist('posters', handler=Poster, \ + poller=_populate_images, sort=True) + keywords = Datalist('keywords', handler=Keyword, \ + poller=_populate_keywords) + releases = Datadict('countries', handler=Release, \ + poller=_populate_releases, attr='country') + youtube_trailers = Datalist('youtube', handler=YoutubeTrailer, \ + poller=_populate_trailers) + apple_trailers = Datalist('quicktime', handler=AppleTrailer, \ + poller=_populate_trailers) + translations = Datalist('translations', handler=Translation, \ + poller=_populate_translations) + + def setFavorite(self, value): + req = Request('account/{0}/favorite'.format(\ + Account(session=self._session).id), + session_id=self._session.sessionid) + req.add_data({'movie_id':self.id, 'favorite':str(bool(value)).lower()}) + req.lifetime = 0 + req.readJSON() + + def setRating(self, value): + if not (0 <= value <= 10): + raise TMDBError("Ratings must be between '0' and '10'.") + req = Request('movie/{0}/rating'.format(self.id), \ + session_id=self._session.sessionid) + req.lifetime = 0 + req.add_data({'value':value}) + req.readJSON() + + def setWatchlist(self, value): + req = Request('account/{0}/movie_watchlist'.format(\ + Account(session=self._session).id), + session_id=self._session.sessionid) + req.lifetime = 0 + req.add_data({'movie_id':self.id, + 'movie_watchlist':str(bool(value)).lower()}) + req.readJSON() + + def getSimilar(self): + return self.similar + + @property + def similar(self): + res = MovieSearchResult(Request('movie/{0}/similar_movies'\ + .format(self.id)), + locale=self._locale) + res._name = 'Similar to {0}'.format(self._printable_name()) + return res + + @property + def lists(self): + res = ListSearchResult(Request('movie/{0}/lists'.format(self.id))) + res._name = "Lists containing {0}".format(self._printable_name()) + return res + + def _printable_name(self): + if self.title is not None: + s = u"'{0}'".format(self.title) + elif self.originaltitle is not None: + s = u"'{0}'".format(self.originaltitle) + else: + s = u"'No Title'" + if self.releasedate: + s = u"{0} ({1})".format(s, self.releasedate.year) + return s + + def __repr__(self): + return u"<{0} {1}>".format(self.__class__.__name__,\ + self._printable_name()).encode('utf-8') + +class ReverseCast( Movie ): + character = Datapoint('character') + + def __repr__(self): + return u"<{0.__class__.__name__} '{0.character}' on {1}>"\ + .format(self, self._printable_name()).encode('utf-8') + +class ReverseCrew( Movie ): + department = Datapoint('department') + job = Datapoint('job') + + def __repr__(self): + return u"<{0.__class__.__name__} '{0.job}' for {1}>"\ + .format(self, self._printable_name()).encode('utf-8') + +class Collection( NameRepr, Element ): + id = Datapoint('id', initarg=1) + name = Datapoint('name') + backdrop = Datapoint('backdrop_path', handler=Backdrop, \ + raw=False, default=None) + poster = Datapoint('poster_path', handler=Poster, \ + raw=False, default=None) + members = Datalist('parts', handler=Movie) + overview = Datapoint('overview') + + def _populate(self): + return Request('collection/{0}'.format(self.id), \ + language=self._locale.language) + def _populate_images(self): + kwargs = {} + if not self._locale.fallthrough: + kwargs['language'] = self._locale.language + return Request('collection/{0}/images'.format(self.id), **kwargs) + + backdrops = Datalist('backdrops', handler=Backdrop, \ + poller=_populate_images, sort=True) + posters = Datalist('posters', handler=Poster, \ + poller=_populate_images, sort=True) + +class List( NameRepr, Element ): + id = Datapoint('id', initarg=1) + name = Datapoint('name') + author = Datapoint('created_by') + description = Datapoint('description') + favorites = Datapoint('favorite_count') + language = Datapoint('iso_639_1') + count = Datapoint('item_count') + poster = Datapoint('poster_path', handler=Poster, \ + raw=False, default=None) + + members = Datalist('items', handler=Movie) + + def _populate(self): + return Request('list/{0}'.format(self.id)) + diff --git a/libs/tmdb3/tmdb_auth.py b/libs/tmdb3/tmdb_auth.py new file mode 100755 index 0000000..8583b99 --- /dev/null +++ b/libs/tmdb3/tmdb_auth.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +#----------------------- +# Name: tmdb_auth.py +# Python Library +# Author: Raymond Wagner +# Purpose: Provide authentication and session services for +# calls against the TMDB v3 API +#----------------------- + +from datetime import datetime as _pydatetime, \ + tzinfo as _pytzinfo +import re +class datetime( _pydatetime ): + """Customized datetime class with ISO format parsing.""" + _reiso = re.compile('(?P[0-9]{4})' + '-(?P[0-9]{1,2})' + '-(?P[0-9]{1,2})' + '.' + '(?P[0-9]{2})' + ':(?P[0-9]{2})' + '(:(?P[0-9]{2}))?' + '(?PZ|' + '(?P[-+])' + '(?P[0-9]{1,2})' + '(:)?' + '(?P[0-9]{2})?' + ')?') + + class _tzinfo( _pytzinfo): + def __init__(self, direc='+', hr=0, min=0): + if direc == '-': + hr = -1*int(hr) + self._offset = timedelta(hours=int(hr), minutes=int(min)) + def utcoffset(self, dt): return self._offset + def tzname(self, dt): return '' + def dst(self, dt): return timedelta(0) + + @classmethod + def fromIso(cls, isotime, sep='T'): + match = cls._reiso.match(isotime) + if match is None: + raise TypeError("time data '%s' does not match ISO 8601 format" \ + % isotime) + + dt = [int(a) for a in match.groups()[:5]] + if match.group('sec') is not None: + dt.append(int(match.group('sec'))) + else: + dt.append(0) + if match.group('tz'): + if match.group('tz') == 'Z': + tz = cls._tzinfo() + elif match.group('tzmin'): + tz = cls._tzinfo(*match.group('tzdirec','tzhour','tzmin')) + else: + tz = cls._tzinfo(*match.group('tzdirec','tzhour')) + dt.append(0) + dt.append(tz) + return cls(*dt) + +from request import Request +from tmdb_exceptions import * + +syssession = None + +def set_session(sessionid): + global syssession + syssession = Session(sessionid) + +def get_session(sessionid=None): + global syssession + if sessionid: + return Session(sessionid) + elif syssession is not None: + return syssession + else: + return Session.new() + +class Session( object ): + + @classmethod + def new(cls): + return cls(None) + + def __init__(self, sessionid): + self.sessionid = sessionid + + @property + def sessionid(self): + if self._sessionid is None: + if self._authtoken is None: + raise TMDBError("No Auth Token to produce Session for") + # TODO: check authtokenexpiration against current time + req = Request('authentication/session/new', \ + request_token=self._authtoken) + req.lifetime = 0 + dat = req.readJSON() + if not dat['success']: + raise TMDBError("Session generation failed") + self._sessionid = dat['session_id'] + return self._sessionid + + @sessionid.setter + def sessionid(self, value): + self._sessionid = value + self._authtoken = None + self._authtokenexpiration = None + if value is None: + self.authenticated = False + else: + self.authenticated = True + + @property + def authtoken(self): + if self.authenticated: + raise TMDBError("Session is already authenticated") + if self._authtoken is None: + req = Request('authentication/token/new') + req.lifetime = 0 + dat = req.readJSON() + if not dat['success']: + raise TMDBError("Auth Token request failed") + self._authtoken = dat['request_token'] + self._authtokenexpiration = datetime.fromIso(dat['expires_at']) + return self._authtoken + + @property + def callbackurl(self): + return "http://www.themoviedb.org/authenticate/"+self._authtoken + diff --git a/libs/tmdb3/tmdb_exceptions.py b/libs/tmdb3/tmdb_exceptions.py new file mode 100755 index 0000000..35e0364 --- /dev/null +++ b/libs/tmdb3/tmdb_exceptions.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +#----------------------- +# Name: tmdb_exceptions.py Common exceptions used in tmdbv3 API library +# Python Library +# Author: Raymond Wagner +#----------------------- + +class TMDBError( Exception ): + Error = 0 + KeyError = 10 + KeyMissing = 20 + KeyInvalid = 30 + KeyRevoked = 40 + RequestError = 50 + RequestInvalid = 51 + PagingIssue = 60 + CacheError = 70 + CacheReadError = 71 + CacheWriteError = 72 + CacheDirectoryError = 73 + ImageSizeError = 80 + HTTPError = 90 + Offline = 100 + LocaleError = 110 + + def __init__(self, msg=None, errno=0): + self.errno = errno + if errno == 0: + self.errno = getattr(self, 'TMDB'+self.__class__.__name__, errno) + self.args = (msg,) + +class TMDBKeyError( TMDBError ): + pass + +class TMDBKeyMissing( TMDBKeyError ): + pass + +class TMDBKeyInvalid( TMDBKeyError ): + pass + +class TMDBKeyRevoked( TMDBKeyInvalid ): + pass + +class TMDBRequestError( TMDBError ): + pass + +class TMDBRequestInvalid( TMDBRequestError ): + pass + +class TMDBPagingIssue( TMDBRequestError ): + pass + +class TMDBCacheError( TMDBRequestError ): + pass + +class TMDBCacheReadError( TMDBCacheError ): + def __init__(self, filename): + super(TMDBCacheReadError, self).__init__( + "User does not have permission to access cache file: {0}.".format(filename)) + self.filename = filename + +class TMDBCacheWriteError( TMDBCacheError ): + def __init__(self, filename): + super(TMDBCacheWriteError, self).__init__( + "User does not have permission to write cache file: {0}.".format(filename)) + self.filename = filename + +class TMDBCacheDirectoryError( TMDBCacheError ): + def __init__(self, filename): + super(TMDBCacheDirectoryError, self).__init__( + "Directory containing cache file does not exist: {0}.".format(filename)) + self.filename = filename + +class TMDBImageSizeError( TMDBError ): + pass + +class TMDBHTTPError( TMDBError ): + def __init__(self, err): + self.httperrno = err.code + self.response = err.fp.read() + super(TMDBHTTPError, self).__init__(str(err)) + +class TMDBOffline( TMDBError ): + pass + +class TMDBLocaleError( TMDBError ): + pass + diff --git a/libs/tmdb3/util.py b/libs/tmdb3/util.py new file mode 100755 index 0000000..bba9fcc --- /dev/null +++ b/libs/tmdb3/util.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +#----------------------- +# Name: util.py Assorted utilities used in tmdb_api +# Python Library +# Author: Raymond Wagner +#----------------------- + +from copy import copy +from locales import get_locale +from tmdb_auth import get_session + +class NameRepr( object ): + """Mixin for __repr__ methods using 'name' attribute.""" + def __repr__(self): + return u"<{0.__class__.__name__} '{0.name}'>"\ + .format(self).encode('utf-8') + +class SearchRepr( object ): + """ + Mixin for __repr__ methods for classes with '_name' and + '_request' attributes. + """ + def __repr__(self): + name = self._name if self._name else self._request._kwargs['query'] + return u"".format(name).encode('utf-8') + +class Poller( object ): + """ + Wrapper for an optional callable to populate an Element derived class + with raw data, or data from a Request. + """ + def __init__(self, func, lookup, inst=None): + self.func = func + self.lookup = lookup + self.inst = inst + if func: + # with function, this allows polling data from the API + self.__doc__ = func.__doc__ + self.__name__ = func.__name__ + self.__module__ = func.__module__ + else: + # without function, this is just a dummy poller used for applying + # raw data to a new Element class with the lookup table + self.__name__ = '_populate' + + def __get__(self, inst, owner): + # normal decorator stuff + # return self for a class + # return instantiated copy of self for an object + if inst is None: + return self + func = None + if self.func: + func = self.func.__get__(inst, owner) + return self.__class__(func, self.lookup, inst) + + def __call__(self): + # retrieve data from callable function, and apply + if not callable(self.func): + raise RuntimeError('Poller object called without a source function') + req = self.func() + if (('language' in req._kwargs) or ('country' in req._kwargs)) \ + and self.inst._locale.fallthrough: + # request specifies a locale filter, and fallthrough is enabled + # run a first pass with specified filter + if not self.apply(req.readJSON(), False): + return + # if first pass results in missed data, run a second pass to + # fill in the gaps + self.apply(req.new(language=None, country=None).readJSON()) + # re-apply the filtered first pass data over top the second + # unfiltered set. this is to work around the issue that the + # properties have no way of knowing when they should or + # should not overwrite existing data. the cache engine will + # take care of the duplicate query + self.apply(req.readJSON()) + + def apply(self, data, set_nones=True): + # apply data directly, bypassing callable function + unfilled = False + for k,v in self.lookup.items(): + if (k in data) and \ + ((data[k] is not None) if callable(self.func) else True): + # argument received data, populate it + setattr(self.inst, v, data[k]) + elif v in self.inst._data: + # argument did not receive data, but Element already contains + # some value, so skip this + continue + elif set_nones: + # argument did not receive data, so fill it with None + # to indicate such and prevent a repeat scan + setattr(self.inst, v, None) + else: + # argument does not need data, so ignore it allowing it to + # trigger a later poll. this is intended for use when + # initializing a class with raw data, or when performing a + # first pass through when performing locale fall through + unfilled = True + return unfilled + +class Data( object ): + """ + Basic response definition class + This maps to a single key in a JSON dictionary received from the API + """ + def __init__(self, field, initarg=None, handler=None, poller=None, + raw=True, default=u'', lang=False): + """ + This defines how the dictionary value is to be processed by the poller + field -- defines the dictionary key that filters what data this uses + initarg -- (optional) specifies that this field must be supplied + when creating a new instance of the Element class this + definition is mapped to. Takes an integer for the order + it should be used in the input arguments + handler -- (optional) callable used to process the received value + before being stored in the Element object. + poller -- (optional) callable to be used if data is requested and + this value has not yet been defined. the callable should + return a dictionary of data from a JSON query. many + definitions may share a single poller, which will be + and the data used to populate all referenced definitions + based off their defined field + raw -- (optional) if the specified handler is an Element class, + the data will be passed into it using the 'raw' keyword + attribute. setting this to false will force the data to + instead be passed in as the first argument + """ + self.field = field + self.initarg = initarg + self.poller = poller + self.raw = raw + self.default = default + self.sethandler(handler) + + def __get__(self, inst, owner): + if inst is None: + return self + if self.field not in inst._data: + if self.poller is None: + return None + self.poller.__get__(inst, owner)() + return inst._data[self.field] + + def __set__(self, inst, value): + if (value is not None) and (value != ''): + value = self.handler(value) + else: + value = self.default + if isinstance(value, Element): + value._locale = inst._locale + value._session = inst._session + inst._data[self.field] = value + + def sethandler(self, handler): + # ensure handler is always callable, even for passthrough data + if handler is None: + self.handler = lambda x: x + elif isinstance(handler, ElementType) and self.raw: + self.handler = lambda x: handler(raw=x) + else: + self.handler = lambda x: handler(x) + +class Datapoint( Data ): + pass + +class Datalist( Data ): + """ + Response definition class for list data + This maps to a key in a JSON dictionary storing a list of data + """ + def __init__(self, field, handler=None, poller=None, sort=None, raw=True): + """ + This defines how the dictionary value is to be processed by the poller + field -- defines the dictionary key that filters what data this uses + handler -- (optional) callable used to process the received value + before being stored in the Element object. + poller -- (optional) callable to be used if data is requested and + this value has not yet been defined. the callable should + return a dictionary of data from a JSON query. many + definitions may share a single poller, which will be + and the data used to populate all referenced definitions + based off their defined field + sort -- (optional) name of attribute in resultant data to be used + to sort the list after processing. this effectively + a handler be defined to process the data into something + that has attributes + raw -- (optional) if the specified handler is an Element class, + the data will be passed into it using the 'raw' keyword + attribute. setting this to false will force the data to + instead be passed in as the first argument + """ + super(Datalist, self).__init__(field, None, handler, poller, raw) + self.sort = sort + def __set__(self, inst, value): + data = [] + if value: + for val in value: + val = self.handler(val) + if isinstance(val, Element): + val._locale = inst._locale + val._session = inst._session + data.append(val) + if self.sort: + if self.sort is True: + data.sort() + else: + data.sort(key=lambda x: getattr(x, self.sort)) + inst._data[self.field] = data + +class Datadict( Data ): + """ + Response definition class for dictionary data + This maps to a key in a JSON dictionary storing a dictionary of data + """ + def __init__(self, field, handler=None, poller=None, raw=True, + key=None, attr=None): + """ + This defines how the dictionary value is to be processed by the poller + field -- defines the dictionary key that filters what data this uses + handler -- (optional) callable used to process the received value + before being stored in the Element object. + poller -- (optional) callable to be used if data is requested and + this value has not yet been defined. the callable should + return a dictionary of data from a JSON query. many + definitions may share a single poller, which will be + and the data used to populate all referenced definitions + based off their defined field + key -- (optional) name of key in resultant data to be used as + the key in the stored dictionary. if this is not the + field name from the source data is used instead + attr -- (optional) name of attribute in resultant data to be used + as the key in the stored dictionary. if this is not + the field name from the source data is used instead + raw -- (optional) if the specified handler is an Element class, + the data will be passed into it using the 'raw' keyword + attribute. setting this to false will force the data to + instead be passed in as the first argument + """ + if key and attr: + raise TypeError("`key` and `attr` cannot both be defined") + super(Datadict, self).__init__(field, None, handler, poller, raw) + if key: + self.getkey = lambda x: x[key] + elif attr: + self.getkey = lambda x: getattr(x, attr) + else: + raise TypeError("Datadict requires `key` or `attr` be defined "+\ + "for populating the dictionary") + def __set__(self, inst, value): + data = {} + if value: + for val in value: + val = self.handler(val) + if isinstance(val, Element): + val._locale = inst._locale + val._session = inst._session + data[self.getkey(val)] = val + inst._data[self.field] = data + +class ElementType( type ): + """ + MetaClass used to pre-process Element-derived classes and set up the + Data definitions + """ + def __new__(mcs, name, bases, attrs): + # any Data or Poller object defined in parent classes must be cloned + # and processed in this class to function properly + # scan through available bases for all such definitions and insert + # a copy into this class's attributes + # run in reverse order so higher priority values overwrite lower ones + data = {} + pollers = {'_populate':None} + + for base in reversed(bases): + if isinstance(base, mcs): + for k, attr in base.__dict__.items(): + if isinstance(attr, Data): + # extract copies of each defined Data element from + # parent classes + attr = copy(attr) + attr.poller = attr.poller.func + data[k] = attr + elif isinstance(attr, Poller): + # extract copies of each defined Poller function + # from parent classes + pollers[k] = attr.func + for k,attr in attrs.items(): + if isinstance(attr, Data): + data[k] = attr + if '_populate' in attrs: + pollers['_populate'] = attrs['_populate'] + + # process all defined Data attribues, testing for use as an initial + # argument, and building a list of what Pollers are used to populate + # which Data points + pollermap = dict([(k,[]) for k in pollers]) + initargs = [] + for k,v in data.items(): + v.name = k + if v.initarg: + initargs.append(v) + if v.poller: + pn = v.poller.__name__ + if pn not in pollermap: + pollermap[pn] = [] + if pn not in pollers: + pollers[pn] = v.poller + pollermap[pn].append(v) + else: + pollermap['_populate'].append(v) + + # wrap each used poller function with a Poller class, and push into + # the new class attributes + for k,v in pollermap.items(): + if len(v) == 0: + continue + lookup = dict([(attr.field, attr.name) for attr in v]) + poller = Poller(pollers[k], lookup) + attrs[k] = poller + # backfill wrapped Poller into each mapped Data object, and ensure + # the data elements are defined for this new class + for attr in v: + attr.poller = poller + attrs[attr.name] = attr + + # build sorted list of arguments used for intialization + attrs['_InitArgs'] = tuple([a.name for a in \ + sorted(initargs, key=lambda x: x.initarg)]) + return type.__new__(mcs, name, bases, attrs) + + def __call__(cls, *args, **kwargs): + obj = cls.__new__(cls) + if ('locale' in kwargs) and (kwargs['locale'] is not None): + obj._locale = kwargs['locale'] + else: + obj._locale = get_locale() + + if 'session' in kwargs: + obj._session = kwargs['session'] + else: + obj._session = get_session() + + obj._data = {} + if 'raw' in kwargs: + # if 'raw' keyword is supplied, create populate object manually + if len(args) != 0: + raise TypeError('__init__() takes exactly 2 arguments (1 given)') + obj._populate.apply(kwargs['raw'], False) + else: + # if not, the number of input arguments must exactly match that + # defined by the Data definitions + if len(args) != len(cls._InitArgs): + raise TypeError('__init__() takes exactly {0} arguments ({1} given)'\ + .format(len(cls._InitArgs)+1, len(args)+1)) + for a,v in zip(cls._InitArgs, args): + setattr(obj, a, v) + + obj.__init__() + return obj + +class Element( object ): + __metaclass__ = ElementType + _lang = 'en' + From b5207bc88cfa2c01baf7f2cb342753eff1035e1b Mon Sep 17 00:00:00 2001 From: Ruud Date: Mon, 2 Sep 2013 14:27:16 +0200 Subject: [PATCH 03/55] Return releasedate as string --- couchpotato/core/providers/info/themoviedb/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/couchpotato/core/providers/info/themoviedb/main.py b/couchpotato/core/providers/info/themoviedb/main.py index b25b841..561cdc7 100644 --- a/couchpotato/core/providers/info/themoviedb/main.py +++ b/couchpotato/core/providers/info/themoviedb/main.py @@ -116,7 +116,7 @@ class TheMovieDb(MovieProvider): }, 'imdb': movie.imdb, 'runtime': movie.runtime, - 'released': movie.releasedate, + 'released': str(movie.releasedate), 'year': year, 'plot': movie.overview, 'genres': genres, From a428d366048009b0b7c9183d2fb07cf159b4565e Mon Sep 17 00:00:00 2001 From: Ruud Date: Mon, 2 Sep 2013 14:35:05 +0200 Subject: [PATCH 04/55] Wrap requests in try for better failing Or would it be worse failing? --- couchpotato/__init__.py | 31 ++++++++++++++------- couchpotato/api.py | 71 ++++++++++++++++++++++++++++++------------------- 2 files changed, 65 insertions(+), 37 deletions(-) diff --git a/couchpotato/__init__.py b/couchpotato/__init__.py index 8dc691d..9ab7d65 100644 --- a/couchpotato/__init__.py +++ b/couchpotato/__init__.py @@ -11,9 +11,11 @@ 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 +27,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 +65,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 77957f1..d133853 100644 --- a/couchpotato/api.py +++ b/couchpotato/api.py @@ -1,4 +1,5 @@ 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 @@ -6,8 +7,12 @@ from tornado.web import RequestHandler, asynchronous import json import threading import tornado +import traceback import urllib +log = CPLog(__name__) + + api = {} api_locks = {} api_nonblock = {} @@ -41,7 +46,11 @@ class NonBlockHandler(RequestHandler): if self.request.connection.stream.closed(): return - self.write(response) + try: + self.write(response) + except: + log.error('Failed doing nonblock request: %s', (traceback.format_exc())) + self.write({'success': False, 'error': 'Failed returning results'}) def on_connection_close(self): @@ -70,33 +79,39 @@ class ApiHandler(RequestHandler): api_locks[route].acquire() - kwargs = {} - for x in self.request.arguments: - kwargs[x] = urllib.unquote(self.get_argument(x)) - - # Split array arguments - kwargs = getParams(kwargs) - - # Remove t random string - try: del kwargs['t'] - except: pass - - # Add async callback handler - @run_async - def run_handler(callback): - result = api[route](**kwargs) - callback(result) - 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) + try: + + kwargs = {} + for x in self.request.arguments: + kwargs[x] = urllib.unquote(self.get_argument(x)) + + # Split array arguments + kwargs = getParams(kwargs) + + # Remove t random string + try: del kwargs['t'] + except: pass + + # Add async callback handler + @run_async + def run_handler(callback): + result = api[route](**kwargs) + callback(result) + 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() From 3baf12d3e40bf6939f2dbbc4c54f4ed539b6bc0d Mon Sep 17 00:00:00 2001 From: Ruud Date: Mon, 2 Sep 2013 14:54:54 +0200 Subject: [PATCH 05/55] Make sure cleanhost only has one trailing slash --- couchpotato/core/helpers/variable.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/couchpotato/core/helpers/variable.py b/couchpotato/core/helpers/variable.py index e6c9f84..9406a20 100644 --- a/couchpotato/core/helpers/variable.py +++ b/couchpotato/core/helpers/variable.py @@ -113,8 +113,8 @@ def cleanHost(host): if not host.startswith(('http://', 'https://')): host = 'http://' + host - if not host.endswith('/'): - host += '/' + host = host.rstrip('/') + host += '/' return host From 2715dbaaa57057996350c73a5b3b0a4227c853df Mon Sep 17 00:00:00 2001 From: Ruud Date: Mon, 2 Sep 2013 15:27:06 +0200 Subject: [PATCH 06/55] Don't do failed checking on local requests --- couchpotato/core/plugins/base.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/couchpotato/core/plugins/base.py b/couchpotato/core/plugins/base.py index 89ef29b..70b8c4b 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 @@ -140,7 +140,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: @@ -203,7 +203,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: From 3e28cd5c954ae8694c30b8337b50457364bbaa07 Mon Sep 17 00:00:00 2001 From: Ruud Date: Mon, 2 Sep 2013 15:27:18 +0200 Subject: [PATCH 07/55] local ip checking helper --- couchpotato/core/helpers/variable.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/couchpotato/core/helpers/variable.py b/couchpotato/core/helpers/variable.py index 9406a20..537b356 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:] From bc778124887f9809c848c9cf3773e6ce15025e17 Mon Sep 17 00:00:00 2001 From: Ruud Date: Mon, 2 Sep 2013 16:49:57 +0200 Subject: [PATCH 08/55] Copy file and maybe copy stats. fix #349 --- couchpotato/core/providers/metadata/base.py | 5 +++++ couchpotato/runner.py | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/couchpotato/core/providers/metadata/base.py b/couchpotato/core/providers/metadata/base.py index d2393dd..8cd1a1c 100644 --- a/couchpotato/core/providers/metadata/base.py +++ b/couchpotato/core/providers/metadata/base.py @@ -48,6 +48,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) diff --git a/couchpotato/runner.py b/couchpotato/runner.py index 2a655d5..715eab5 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 = [] From 7d32a8750d8c1d4314d8d297a33aa6290692c726 Mon Sep 17 00:00:00 2001 From: Ruud Date: Mon, 2 Sep 2013 16:53:39 +0200 Subject: [PATCH 09/55] type > protocol --- couchpotato/core/plugins/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/couchpotato/core/plugins/base.py b/couchpotato/core/plugins/base.py index 47ef4ed..e60aaf4 100644 --- a/couchpotato/core/plugins/base.py +++ b/couchpotato/core/plugins/base.py @@ -292,7 +292,7 @@ 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') == 'nzb' and 'DOCTYPE nzb' not in filedata and '' not in filedata: return '%s.%s' % (name, 'rar') return '%s.%s' % (name, data.get('protocol')) From 7fd14e0283b2ffc96158bbcc795fad8b9fa9444a Mon Sep 17 00:00:00 2001 From: Ruud Date: Mon, 2 Sep 2013 21:59:06 +0200 Subject: [PATCH 10/55] Code cleanup --- couchpotato/__init__.py | 3 --- couchpotato/api.py | 2 +- couchpotato/core/_base/clientscript/main.py | 2 +- couchpotato/core/auth.py | 5 ++++ couchpotato/core/downloaders/base.py | 8 +++--- couchpotato/core/downloaders/blackhole/main.py | 2 +- couchpotato/core/downloaders/deluge/main.py | 4 +-- couchpotato/core/downloaders/nzbget/main.py | 6 ++++- couchpotato/core/downloaders/nzbvortex/main.py | 4 +-- couchpotato/core/downloaders/sabnzbd/main.py | 5 ++-- couchpotato/core/downloaders/synology/main.py | 10 ++++---- couchpotato/core/downloaders/transmission/main.py | 9 ++++--- couchpotato/core/downloaders/utorrent/main.py | 4 +-- couchpotato/core/event.py | 4 ++- couchpotato/core/helpers/encoding.py | 2 +- couchpotato/core/helpers/request.py | 2 +- couchpotato/core/helpers/rss.py | 6 ++--- couchpotato/core/helpers/variable.py | 10 ++++---- couchpotato/core/loader.py | 4 +-- couchpotato/core/media/__init__.py | 1 - couchpotato/core/media/_base/searcher/__init__.py | 1 - couchpotato/core/media/_base/searcher/base.py | 20 ++++++--------- couchpotato/core/media/_base/searcher/main.py | 5 ++-- couchpotato/core/media/movie/_base/main.py | 22 ++++++++-------- couchpotato/core/media/movie/library/movie/main.py | 3 ++- couchpotato/core/notifications/base.py | 1 + couchpotato/core/notifications/nmj/main.py | 17 ++++++------- .../core/notifications/notifymyandroid/main.py | 5 +--- couchpotato/core/notifications/plex/main.py | 2 +- couchpotato/core/notifications/synoindex/main.py | 3 +-- couchpotato/core/notifications/twitter/main.py | 3 ++- couchpotato/core/notifications/xbmc/main.py | 18 +++++++------- couchpotato/core/plugins/base.py | 4 ++- couchpotato/core/plugins/browser/main.py | 2 +- couchpotato/core/plugins/dashboard/main.py | 2 +- couchpotato/core/plugins/log/main.py | 1 - couchpotato/core/plugins/manage/main.py | 4 +-- couchpotato/core/plugins/profile/main.py | 2 +- couchpotato/core/plugins/quality/main.py | 2 +- couchpotato/core/plugins/release/main.py | 5 ++-- couchpotato/core/plugins/renamer/main.py | 29 +++++++++++----------- couchpotato/core/plugins/scanner/main.py | 23 +++++++++-------- couchpotato/core/plugins/score/main.py | 2 +- couchpotato/core/plugins/score/scores.py | 8 +++--- couchpotato/core/plugins/status/main.py | 2 +- couchpotato/core/plugins/subtitle/main.py | 2 +- couchpotato/core/plugins/suggestion/main.py | 1 + couchpotato/core/providers/automation/imdb/main.py | 2 +- couchpotato/core/providers/info/omdbapi/main.py | 2 +- couchpotato/core/providers/info/themoviedb/main.py | 12 +++------ couchpotato/core/providers/metadata/base.py | 7 ++++-- couchpotato/core/providers/nzb/binsearch/main.py | 6 +++-- couchpotato/core/providers/nzb/newznab/main.py | 2 +- couchpotato/core/providers/torrent/scenehd/main.py | 2 +- .../core/providers/torrent/thepiratebay/main.py | 8 +++--- .../core/providers/userscript/allocine/main.py | 3 --- couchpotato/core/settings/__init__.py | 7 ++---- couchpotato/environment.py | 4 +-- couchpotato/runner.py | 4 +-- 59 files changed, 171 insertions(+), 170 deletions(-) diff --git a/couchpotato/__init__.py b/couchpotato/__init__.py index 9ab7d65..089ecd4 100644 --- a/couchpotato/__init__.py +++ b/couchpotato/__init__.py @@ -4,9 +4,6 @@ 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 diff --git a/couchpotato/api.py b/couchpotato/api.py index d133853..e8970dc 100644 --- a/couchpotato/api.py +++ b/couchpotato/api.py @@ -104,7 +104,7 @@ class ApiHandler(RequestHandler): if jsonp_callback: self.write(str(jsonp_callback) + '(' + json.dumps(result) + ')') - elif isinstance(result, (tuple)) and result[0] == 'redirect': + elif isinstance(result, tuple) and result[0] == 'redirect': self.redirect(result[1]) else: self.write(result) diff --git a/couchpotato/core/_base/clientscript/main.py b/couchpotato/core/_base/clientscript/main.py index fece6fa..7476f39 100644 --- a/couchpotato/core/_base/clientscript/main.py +++ b/couchpotato/core/_base/clientscript/main.py @@ -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 diff --git a/couchpotato/core/auth.py b/couchpotato/core/auth.py index e58016b..e877860 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 cc0d59e..3b160c6 100644 --- a/couchpotato/core/downloaders/base.py +++ b/couchpotato/core/downloaders/base.py @@ -119,7 +119,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): @@ -128,7 +128,7 @@ class Downloader(Provider): 'id': download_id } - def isDisabled(self, manual, data): + def isDisabled(self, manual = False, data = {}): return not self.isEnabled(manual, data) def _isEnabled(self, manual, data = {}): @@ -136,10 +136,10 @@ class Downloader(Provider): return return True - def isEnabled(self, manual, data = {}): + def isEnabled(self, manual = False, data = {}): d_manual = self.conf('manual', default = False) return super(Downloader, self).isEnabled() and \ - ((d_manual and manual) or (d_manual is False)) and \ + (d_manual and manual or d_manual is False) and \ (not data or self.isCorrectProtocol(data.get('protocol'))) def _pause(self, item, pause = True): diff --git a/couchpotato/core/downloaders/blackhole/main.py b/couchpotato/core/downloaders/blackhole/main.py index 9d2a526..750c6c7 100644 --- a/couchpotato/core/downloaders/blackhole/main.py +++ b/couchpotato/core/downloaders/blackhole/main.py @@ -62,7 +62,7 @@ class Blackhole(Downloader): else: return ['nzb'] - def isEnabled(self, manual, data = {}): + def isEnabled(self, manual = False, data = {}): for_protocol = ['both'] if data and 'torrent' in data.get('protocol'): for_protocol.append('torrent') diff --git a/couchpotato/core/downloaders/deluge/main.py b/couchpotato/core/downloaders/deluge/main.py index 6a9eb3c..580ed7f 100644 --- a/couchpotato/core/downloaders/deluge/main.py +++ b/couchpotato/core/downloaders/deluge/main.py @@ -54,7 +54,7 @@ class Deluge(Downloader): if self.conf('completed_directory'): if os.path.isdir(self.conf('completed_directory')): - options['move_completed'] = 1 + 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')) @@ -96,7 +96,7 @@ class Deluge(Downloader): queue = self.drpc.get_alltorrents() - if not (queue): + if not queue: log.debug('Nothing in queue or error') return False diff --git a/couchpotato/core/downloaders/nzbget/main.py b/couchpotato/core/downloaders/nzbget/main.py index 04f68f4..adeedea 100644 --- a/couchpotato/core/downloaders/nzbget/main.py +++ b/couchpotato/core/downloaders/nzbget/main.py @@ -172,11 +172,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/main.py b/couchpotato/core/downloaders/nzbvortex/main.py index 2944c32..1e0a4b2 100644 --- a/couchpotato/core/downloaders/nzbvortex/main.py +++ b/couchpotato/core/downloaders/nzbvortex/main.py @@ -122,7 +122,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: @@ -148,7 +148,7 @@ class NZBVortex(Downloader): return self.api_level - def isEnabled(self, manual, data): + def isEnabled(self, manual = False, data = {}): return super(NZBVortex, self).isEnabled(manual, data) and self.getApiLevel() diff --git a/couchpotato/core/downloaders/sabnzbd/main.py b/couchpotato/core/downloaders/sabnzbd/main.py index 68bbd26..110c55b 100644 --- a/couchpotato/core/downloaders/sabnzbd/main.py +++ b/couchpotato/core/downloaders/sabnzbd/main.py @@ -26,9 +26,10 @@ class Sabnzbd(Downloader): '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 @@ -38,7 +39,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) diff --git a/couchpotato/core/downloaders/synology/main.py b/couchpotato/core/downloaders/synology/main.py index 362577f..58ab099 100644 --- a/couchpotato/core/downloaders/synology/main.py +++ b/couchpotato/core/downloaders/synology/main.py @@ -49,7 +49,7 @@ class Synology(Downloader): else: return ['nzb'] - def isEnabled(self, manual, data = {}): + def isEnabled(self, manual = False, data = {}): for_protocol = ['both'] if data and 'torrent' in data.get('protocol'): for_protocol.append('torrent') @@ -61,7 +61,7 @@ class Synology(Downloader): class SynologyRPC(object): - '''SynologyRPC lite library''' + """SynologyRPC lite library""" def __init__(self, host = 'localhost', port = 5000, username = None, password = None): @@ -98,7 +98,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 +111,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/main.py b/couchpotato/core/downloaders/transmission/main.py index be7f2f7..5ff33c0 100644 --- a/couchpotato/core/downloaders/transmission/main.py +++ b/couchpotato/core/downloaders/transmission/main.py @@ -44,8 +44,9 @@ class Transmission(Downloader): return False # Set parameters for adding torrent - params = {} - params['paused'] = self.conf('paused', default = False) + params = { + 'paused': self.conf('paused', default = False) + } if self.conf('directory'): if os.path.isdir(self.conf('directory')): @@ -135,11 +136,11 @@ class Transmission(Downloader): def removeFailed(self, item): log.info('%s failed downloading, deleting...', item['name']) - return self.trpc.remove_torrent(self, item['hashString'], True) + 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(self, item['hashString'], delete_files) + return self.trpc.remove_torrent(item['hashString'], delete_files) class TransmissionRPC(object): diff --git a/couchpotato/core/downloaders/utorrent/main.py b/couchpotato/core/downloaders/utorrent/main.py index 59e9a4a..e1b9938 100644 --- a/couchpotato/core/downloaders/utorrent/main.py +++ b/couchpotato/core/downloaders/utorrent/main.py @@ -1,5 +1,5 @@ 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 @@ -74,7 +74,7 @@ class uTorrent(Downloader): 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'): diff --git a/couchpotato/core/event.py b/couchpotato/core/event.py index 0e0b4a7..30c5189 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: diff --git a/couchpotato/core/helpers/encoding.py b/couchpotato/core/helpers/encoding.py index 9b753db..6e86444 100644 --- a/couchpotato/core/helpers/encoding.py +++ b/couchpotato/core/helpers/encoding.py @@ -63,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 c224979..888e63f 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 d88fdb5..b840d86 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 537b356..8f393d0 100644 --- a/couchpotato/core/helpers/variable.py +++ b/couchpotato/core/helpers/variable.py @@ -179,11 +179,11 @@ def getTitle(library_dict): def possibleTitles(raw_title): - titles = [] - - titles.append(toSafeString(raw_title).lower()) - titles.append(raw_title.lower()) - titles.append(simplifyString(raw_title)) + titles = [ + toSafeString(raw_title).lower(), + raw_title.lower(), + simplifyString(raw_title) + ] # replace some chars new_title = raw_title.replace('&', 'and') diff --git a/couchpotato/core/loader.py b/couchpotato/core/loader.py index 745c75d..f101105 100644 --- a/couchpotato/core/loader.py +++ b/couchpotato/core/loader.py @@ -66,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. @@ -122,7 +122,7 @@ class Loader(object): try: module.start() return True - except Exception, e: + except: log.error('Failed loading plugin "%s": %s', (module.__file__, traceback.format_exc())) return False diff --git a/couchpotato/core/media/__init__.py b/couchpotato/core/media/__init__.py index 8187f98..1cef967 100644 --- a/couchpotato/core/media/__init__.py +++ b/couchpotato/core/media/__init__.py @@ -1,5 +1,4 @@ from couchpotato.core.event import addEvent -from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin diff --git a/couchpotato/core/media/_base/searcher/__init__.py b/couchpotato/core/media/_base/searcher/__init__.py index f3d764d..0fb6cc0 100644 --- a/couchpotato/core/media/_base/searcher/__init__.py +++ b/couchpotato/core/media/_base/searcher/__init__.py @@ -1,5 +1,4 @@ from .main import Searcher -import random def start(): return Searcher() diff --git a/couchpotato/core/media/_base/searcher/base.py b/couchpotato/core/media/_base/searcher/base.py index ab29439..368c6e2 100644 --- a/couchpotato/core/media/_base/searcher/base.py +++ b/couchpotato/core/media/_base/searcher/base.py @@ -1,4 +1,3 @@ -from couchpotato.api import addApiView from couchpotato.core.event import addEvent, fireEvent from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin @@ -19,12 +18,10 @@ class SearcherBase(Plugin): self.initCron() - - """ Set the searcher cronjob - Make sure to reset cronjob after setting has changed - - """ def initCron(self): + """ Set the searcher cronjob + Make sure to reset cronjob after setting has changed + """ _type = self.getType() @@ -38,14 +35,11 @@ class SearcherBase(Plugin): addEvent('setting.save.%s_searcher.cron_hour.after' % _type, setCrons) addEvent('setting.save.%s_searcher.cron_minute.after' % _type, setCrons) - - """ Return progress of current searcher - - """ def getProgress(self, **kwargs): + """ Return progress of current searcher""" - progress = {} - progress[self.getType()] = self.in_progress + 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 index 7d84a58..3222edb 100644 --- a/couchpotato/core/media/_base/searcher/main.py +++ b/couchpotato/core/media/_base/searcher/main.py @@ -173,10 +173,10 @@ class Searcher(SearcherBase): 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)) + 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)) + 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 @@ -191,6 +191,7 @@ class Searcher(SearcherBase): if not isinstance(haystack, (list, tuple, set)): haystack = [haystack] + year_name = {} for string in haystack: year_name = fireEvent('scanner.name_year', string, single = True) diff --git a/couchpotato/core/media/movie/_base/main.py b/couchpotato/core/media/movie/_base/main.py index 518893f..a8d890d 100644 --- a/couchpotato/core/media/movie/_base/main.py +++ b/couchpotato/core/media/movie/_base/main.py @@ -145,7 +145,7 @@ class MovieBase(MovieTypeBase): 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() @@ -231,7 +231,7 @@ class MovieBase(MovieTypeBase): })) db.expire_all() - return (total_count, movies) + return total_count, movies def availableChars(self, status = None, release_status = None): @@ -270,12 +270,12 @@ class MovieBase(MovieTypeBase): 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, @@ -372,7 +372,7 @@ class MovieBase(MovieTypeBase): fireEvent('status.get', ['active', 'snatched', 'ignored', 'done', 'downloaded'], single = True) default_profile = fireEvent('profile.default', single = True) - cat_id = params.get('category_id', None) + cat_id = params.get('category_id') db = get_session() m = db.query(Movie).filter_by(library_id = library.get('id')).first() @@ -463,7 +463,7 @@ class MovieBase(MovieTypeBase): m.profile_id = kwargs.get('profile_id') - cat_id = kwargs.get('category_id', None) + 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 @@ -559,7 +559,7 @@ class MovieBase(MovieTypeBase): 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: diff --git a/couchpotato/core/media/movie/library/movie/main.py b/couchpotato/core/media/movie/library/movie/main.py index 98da582..12e6abc 100644 --- a/couchpotato/core/media/movie/library/movie/main.py +++ b/couchpotato/core/media/movie/library/movie/main.py @@ -2,8 +2,8 @@ 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.settings.model import Library, LibraryTitle, File from couchpotato.core.media._base.library import LibraryBase +from couchpotato.core.settings.model import Library, LibraryTitle, File from string import ascii_letters import time import traceback @@ -66,6 +66,7 @@ class MovieLibraryPlugin(LibraryBase): 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) diff --git a/couchpotato/core/notifications/base.py b/couchpotato/core/notifications/base.py index 7418e1a..61cb212 100644 --- a/couchpotato/core/notifications/base.py +++ b/couchpotato/core/notifications/base.py @@ -45,6 +45,7 @@ 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 diff --git a/couchpotato/core/notifications/nmj/main.py b/couchpotato/core/notifications/nmj/main.py index 695f53b..43bb950 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() @@ -73,9 +72,9 @@ class NMJ(Notification): 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 +97,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/notifymyandroid/main.py b/couchpotato/core/notifications/notifymyandroid/main.py index 2c4ac90..5e5bfb9 100644 --- a/couchpotato/core/notifications/notifymyandroid/main.py +++ b/couchpotato/core/notifications/notifymyandroid/main.py @@ -15,12 +15,9 @@ class NotifyMyAndroid(Notification): 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 02c9b30..9becfb5 100644 --- a/couchpotato/core/notifications/plex/main.py +++ b/couchpotato/core/notifications/plex/main.py @@ -37,7 +37,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))) diff --git a/couchpotato/core/notifications/synoindex/main.py b/couchpotato/core/notifications/synoindex/main.py index 315520e..3980430 100644 --- a/couchpotato/core/notifications/synoindex/main.py +++ b/couchpotato/core/notifications/synoindex/main.py @@ -27,9 +27,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/twitter/main.py b/couchpotato/core/notifications/twitter/main.py index facc36b..4a34d60 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__) diff --git a/couchpotato/core/notifications/xbmc/main.py b/couchpotato/core/notifications/xbmc/main.py index 34a9c1d..836b40c 100755 --- a/couchpotato/core/notifications/xbmc/main.py +++ b/couchpotato/core/notifications/xbmc/main.py @@ -53,9 +53,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: @@ -72,7 +72,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']) @@ -85,15 +85,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']) @@ -108,16 +108,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/base.py b/couchpotato/core/plugins/base.py index e60aaf4..92a7efa 100644 --- a/couchpotato/core/plugins/base.py +++ b/couchpotato/core/plugins/base.py @@ -26,11 +26,13 @@ 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 = {} @@ -306,4 +308,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 6b989a0..380e682 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/dashboard/main.py b/couchpotato/core/plugins/dashboard/main.py index df6f975..b02e5d9 100644 --- a/couchpotato/core/plugins/dashboard/main.py +++ b/couchpotato/core/plugins/dashboard/main.py @@ -93,7 +93,7 @@ class Dashboard(Plugin): }) # 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 \ + 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) diff --git a/couchpotato/core/plugins/log/main.py b/couchpotato/core/plugins/log/main.py index 18a78b9..dc8f740 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() diff --git a/couchpotato/core/plugins/manage/main.py b/couchpotato/core/plugins/manage/main.py index 516cb88..f1ffefa 100644 --- a/couchpotato/core/plugins/manage/main.py +++ b/couchpotato/core/plugins/manage/main.py @@ -184,7 +184,7 @@ class Manage(Plugin): fireEvent('release.add', group = group) 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 @@ -195,7 +195,7 @@ class Manage(Plugin): if not self.in_progress or self.shuttingDown(): return - self.in_progress[folder]['to_go'] = self.in_progress[folder]['to_go'] - 1 + 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/profile/main.py b/couchpotato/core/plugins/profile/main.py index c70d7c9..27963fb 100644 --- a/couchpotato/core/plugins/profile/main.py +++ b/couchpotato/core/plugins/profile/main.py @@ -155,7 +155,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 67c7f00..0e67a07 100644 --- a/couchpotato/core/plugins/quality/main.py +++ b/couchpotato/core/plugins/quality/main.py @@ -102,7 +102,7 @@ class QualityPlugin(Plugin): def fill(self): - db = get_session(); + db = get_session() order = 0 for q in self.qualities: diff --git a/couchpotato/core/plugins/release/main.py b/couchpotato/core/plugins/release/main.py index 03b2bec..b2cc4e5 100644 --- a/couchpotato/core/plugins/release/main.py +++ b/couchpotato/core/plugins/release/main.py @@ -8,6 +8,7 @@ from couchpotato.core.plugins.scanner.main import Scanner from couchpotato.core.settings.model import File, Release as Relea, Movie from sqlalchemy.sql.expression import and_, or_ import os +import traceback log = CPLog(__name__) @@ -88,8 +89,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) diff --git a/couchpotato/core/plugins/renamer/main.py b/couchpotato/core/plugins/renamer/main.py index 888e069..a45a265 100644 --- a/couchpotato/core/plugins/renamer/main.py +++ b/couchpotato/core/plugins/renamer/main.py @@ -9,8 +9,7 @@ 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, RarInfo -from unrar2.rar_exceptions import * +from unrar2 import RarFile import errno import fnmatch import os @@ -62,10 +61,10 @@ 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: @@ -98,7 +97,7 @@ 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 @@ -131,8 +130,8 @@ class Renamer(Plugin): # 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)) + 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) @@ -347,7 +346,7 @@ class Renamer(Plugin): 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_ent.movies: @@ -517,7 +516,7 @@ class Renamer(Plugin): def tagDir(self, group, tag): ignore_file = None - if isinstance(group, (dict)): + 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 @@ -603,9 +602,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: @@ -873,7 +872,7 @@ Remove it if you want it to be renamed (again, or at least let it try again) #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']))): + if self.hastagDir(os.path.dirname(archive['file'])): continue # Find all related archive files @@ -970,4 +969,4 @@ Remove it if you want it to be renamed (again, or at least let it try again) files = [] folder = None - return (folder, movie_folder, files, extr_files) + return folder, movie_folder, files, extr_files diff --git a/couchpotato/core/plugins/scanner/main.py b/couchpotato/core/plugins/scanner/main.py index 553fa83..188924a 100644 --- a/couchpotato/core/plugins/scanner/main.py +++ b/couchpotato/core/plugins/scanner/main.py @@ -429,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 @@ -508,6 +508,7 @@ class Scanner(Plugin): detected_languages = {} # Subliminal scanner + paths = None try: paths = group['files']['movie'] scan_result = [] @@ -560,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 @@ -585,11 +588,12 @@ 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 @@ -680,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'] diff --git a/couchpotato/core/plugins/score/main.py b/couchpotato/core/plugins/score/main.py index cc87c9a..5f9da1a 100644 --- a/couchpotato/core/plugins/score/main.py +++ b/couchpotato/core/plugins/score/main.py @@ -17,7 +17,7 @@ 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 """ # Merge global and category preferred_words = splitString(Env.setting('preferred_words', section = 'searcher').lower()) diff --git a/couchpotato/core/plugins/score/scores.py b/couchpotato/core/plugins/score/scores.py index a95b0a4..6aa0b46 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 @@ -24,7 +24,7 @@ name_scores = [ def nameScore(name, year, preferred_words): - ''' Calculate score for words in the NZB name ''' + """ Calculate score for words in the NZB name """ score = 0 name = name.lower() @@ -34,11 +34,11 @@ def nameScore(name, year, preferred_words): 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)) diff --git a/couchpotato/core/plugins/status/main.py b/couchpotato/core/plugins/status/main.py index 8db2bf7..7546c65 100644 --- a/couchpotato/core/plugins/status/main.py +++ b/couchpotato/core/plugins/status/main.py @@ -75,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 ea836f0..2aa22f4 100644 --- a/couchpotato/core/plugins/subtitle/main.py +++ b/couchpotato/core/plugins/subtitle/main.py @@ -36,7 +36,7 @@ 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) diff --git a/couchpotato/core/plugins/suggestion/main.py b/couchpotato/core/plugins/suggestion/main.py index 0e7d701..a7b640e 100644 --- a/couchpotato/core/plugins/suggestion/main.py +++ b/couchpotato/core/plugins/suggestion/main.py @@ -47,6 +47,7 @@ class Suggestion(Plugin): ignored = splitString(Env.prop('suggest_ignore', default = '')) + new_suggestions = [] if imdb: if not remove_only: ignored.append(imdb) diff --git a/couchpotato/core/providers/automation/imdb/main.py b/couchpotato/core/providers/automation/imdb/main.py index c4aef7f..e9d14b5 100644 --- a/couchpotato/core/providers/automation/imdb/main.py +++ b/couchpotato/core/providers/automation/imdb/main.py @@ -58,7 +58,7 @@ class IMDBWatchlist(IMDBBase): 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 diff --git a/couchpotato/core/providers/info/omdbapi/main.py b/couchpotato/core/providers/info/omdbapi/main.py index 2726ef5..87bb0a7 100755 --- a/couchpotato/core/providers/info/omdbapi/main.py +++ b/couchpotato/core/providers/info/omdbapi/main.py @@ -98,7 +98,7 @@ class OMDBAPI(MovieProvider): '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/info/themoviedb/main.py b/couchpotato/core/providers/info/themoviedb/main.py index 561cdc7..2ec0e94 100644 --- a/couchpotato/core/providers/info/themoviedb/main.py +++ b/couchpotato/core/providers/info/themoviedb/main.py @@ -23,7 +23,7 @@ class TheMovieDb(MovieProvider): tmdb3.set_cache(engine='file', filename=os.path.join(Env.get('cache_dir'), 'python', 'tmdb.cache')) def search(self, q, limit = 12): - ''' Find movie by name ''' + """ Find movie by name """ if self.isDisabled(): return False @@ -72,9 +72,6 @@ class TheMovieDb(MovieProvider): result = self.getCache(cache_key) if not result: - result = {} - movie = None - try: log.debug('Getting info: %s', cache_key) movie = tmdb3.Movie(identifier) @@ -129,7 +126,7 @@ class TheMovieDb(MovieProvider): 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 != None: + 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'])) @@ -149,6 +146,5 @@ class TheMovieDb(MovieProvider): def isDisabled(self): if self.conf('api_key') == '': log.error('No API key provided.') - True - else: - False + return True + return False diff --git a/couchpotato/core/providers/metadata/base.py b/couchpotato/core/providers/metadata/base.py index 8cd1a1c..7a65568 100644 --- a/couchpotato/core/providers/metadata/base.py +++ b/couchpotato/core/providers/metadata/base.py @@ -82,8 +82,11 @@ class MetaDataBase(Plugin): def getThumbnail(self, movie_info = {}, data = {}, wanted_file_type = 'poster_original'): 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 diff --git a/couchpotato/core/providers/nzb/binsearch/main.py b/couchpotato/core/providers/nzb/binsearch/main.py index 1d86300..dee5fc7 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/newznab/main.py b/couchpotato/core/providers/nzb/newznab/main.py index 8eb3e84..02ffcfd 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/torrent/scenehd/main.py b/couchpotato/core/providers/torrent/scenehd/main.py index f471ec0..2b76e43 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/main.py b/couchpotato/core/providers/torrent/thepiratebay/main.py index 82cbbe9..6aa2216 100644 --- a/couchpotato/core/providers/torrent/thepiratebay/main.py +++ b/couchpotato/core/providers/torrent/thepiratebay/main.py @@ -86,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/userscript/allocine/main.py b/couchpotato/core/providers/userscript/allocine/main.py index 8cc889e..f8ca630 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/settings/__init__.py b/couchpotato/core/settings/__init__.py index e08adb8..b75e5dd 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.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): @@ -92,7 +89,7 @@ class Settings(object): 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) diff --git a/couchpotato/environment.py b/couchpotato/environment.py index ac0f729..0f04d83 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 715eab5..ab92919 100644 --- a/couchpotato/runner.py +++ b/couchpotato/runner.py @@ -112,7 +112,7 @@ def runCouchPotato(options, base_path, args, data_dir = None, log_dir = None, En try: if os.path.isfile(file_path): os.remove(file_path) - except Exception, e: + except: raise os.rmdir(backup) @@ -257,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 From 779c7d29423ce86082f25889324d22d6a82bc5ee Mon Sep 17 00:00:00 2001 From: Ruud Date: Mon, 2 Sep 2013 22:44:44 +0200 Subject: [PATCH 11/55] Remove mutable objects from function args --- couchpotato/core/downloaders/base.py | 17 +++++++++++++---- couchpotato/core/downloaders/blackhole/main.py | 7 +++++-- couchpotato/core/downloaders/nzbget/main.py | 4 +++- couchpotato/core/downloaders/nzbvortex/main.py | 10 +++++++--- couchpotato/core/downloaders/pneumatic/main.py | 4 +++- couchpotato/core/downloaders/sabnzbd/main.py | 4 +++- couchpotato/core/downloaders/synology/main.py | 8 ++++++-- couchpotato/core/downloaders/utorrent/main.py | 8 ++++++-- couchpotato/core/media/_base/searcher/main.py | 3 ++- couchpotato/core/media/movie/_base/main.py | 3 ++- couchpotato/core/media/movie/library/movie/main.py | 4 +++- couchpotato/core/notifications/base.py | 8 +++++--- couchpotato/core/notifications/boxcar/main.py | 3 ++- couchpotato/core/notifications/core/main.py | 6 ++++-- couchpotato/core/notifications/email/main.py | 3 ++- couchpotato/core/notifications/growl/main.py | 3 ++- couchpotato/core/notifications/nmj/main.py | 3 ++- couchpotato/core/notifications/notifo/main.py | 3 ++- .../core/notifications/notifymyandroid/main.py | 3 ++- couchpotato/core/notifications/plex/main.py | 6 ++++-- couchpotato/core/notifications/prowl/main.py | 3 ++- couchpotato/core/notifications/pushalot/main.py | 3 ++- couchpotato/core/notifications/pushover/main.py | 3 ++- couchpotato/core/notifications/synoindex/main.py | 3 ++- couchpotato/core/notifications/toasty/main.py | 3 ++- couchpotato/core/notifications/trakt/main.py | 3 ++- couchpotato/core/notifications/twitter/main.py | 3 ++- couchpotato/core/notifications/xbmc/main.py | 3 ++- couchpotato/core/plugins/file/main.py | 7 +++++-- couchpotato/core/plugins/manage/main.py | 7 +++++-- couchpotato/core/plugins/quality/main.py | 3 ++- couchpotato/core/plugins/renamer/main.py | 13 ++++++++----- couchpotato/core/plugins/subtitle/main.py | 1 - couchpotato/core/plugins/trailer/main.py | 5 ++--- .../core/providers/info/couchpotatoapi/main.py | 5 ++++- couchpotato/core/providers/metadata/base.py | 19 +++++++++++++------ couchpotato/core/providers/metadata/xbmc/main.py | 4 +++- couchpotato/core/settings/__init__.py | 4 +++- couchpotato/core/settings/model.py | 10 ++++++++-- 39 files changed, 147 insertions(+), 65 deletions(-) diff --git a/couchpotato/core/downloaders/base.py b/couchpotato/core/downloaders/base.py index 3b160c6..08be4bd 100644 --- a/couchpotato/core/downloaders/base.py +++ b/couchpotato/core/downloaders/base.py @@ -49,7 +49,10 @@ class Downloader(Provider): 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) @@ -128,15 +131,21 @@ class Downloader(Provider): 'id': download_id } - def isDisabled(self, manual = False, 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 = False, 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 \ diff --git a/couchpotato/core/downloaders/blackhole/main.py b/couchpotato/core/downloaders/blackhole/main.py index 750c6c7..9a5a621 100644 --- a/couchpotato/core/downloaders/blackhole/main.py +++ b/couchpotato/core/downloaders/blackhole/main.py @@ -12,7 +12,9 @@ class Blackhole(Downloader): 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): @@ -62,7 +64,8 @@ class Blackhole(Downloader): else: return ['nzb'] - def isEnabled(self, manual = False, data = {}): + 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') diff --git a/couchpotato/core/downloaders/nzbget/main.py b/couchpotato/core/downloaders/nzbget/main.py index adeedea..35d47de 100644 --- a/couchpotato/core/downloaders/nzbget/main.py +++ b/couchpotato/core/downloaders/nzbget/main.py @@ -19,7 +19,9 @@ class NZBGet(Downloader): 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()) diff --git a/couchpotato/core/downloaders/nzbvortex/main.py b/couchpotato/core/downloaders/nzbvortex/main.py index 1e0a4b2..a652f11 100644 --- a/couchpotato/core/downloaders/nzbvortex/main.py +++ b/couchpotato/core/downloaders/nzbvortex/main.py @@ -23,7 +23,9 @@ class NZBVortex(Downloader): 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: @@ -97,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() @@ -148,7 +151,8 @@ class NZBVortex(Downloader): return self.api_level - def isEnabled(self, manual = False, 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 25923e0..643350e 100644 --- a/couchpotato/core/downloaders/pneumatic/main.py +++ b/couchpotato/core/downloaders/pneumatic/main.py @@ -12,7 +12,9 @@ class Pneumatic(Downloader): 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): diff --git a/couchpotato/core/downloaders/sabnzbd/main.py b/couchpotato/core/downloaders/sabnzbd/main.py index 110c55b..08ee409 100644 --- a/couchpotato/core/downloaders/sabnzbd/main.py +++ b/couchpotato/core/downloaders/sabnzbd/main.py @@ -15,7 +15,9 @@ class Sabnzbd(Downloader): 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')) diff --git a/couchpotato/core/downloaders/synology/main.py b/couchpotato/core/downloaders/synology/main.py index 58ab099..d5082c7 100644 --- a/couchpotato/core/downloaders/synology/main.py +++ b/couchpotato/core/downloaders/synology/main.py @@ -12,7 +12,9 @@ class Synology(Downloader): 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['protocol'])) @@ -49,7 +51,9 @@ class Synology(Downloader): else: return ['nzb'] - def isEnabled(self, manual = False, data = {}): + 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') diff --git a/couchpotato/core/downloaders/utorrent/main.py b/couchpotato/core/downloaders/utorrent/main.py index e1b9938..d933007 100644 --- a/couchpotato/core/downloaders/utorrent/main.py +++ b/couchpotato/core/downloaders/utorrent/main.py @@ -36,7 +36,9 @@ class uTorrent(Downloader): return self.utorrent_api - 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.debug('Sending "%s" (%s) to uTorrent.', (data.get('name'), data.get('protocol'))) @@ -280,7 +282,9 @@ class uTorrentAPI(object): return settings_dict - def set_settings(self, 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 diff --git a/couchpotato/core/media/_base/searcher/main.py b/couchpotato/core/media/_base/searcher/main.py index 3222edb..89afd75 100644 --- a/couchpotato/core/media/_base/searcher/main.py +++ b/couchpotato/core/media/_base/searcher/main.py @@ -146,7 +146,8 @@ class Searcher(SearcherBase): return search_protocols - def containsOtherQuality(self, nzb, movie_year = None, preferred_quality = {}): + def containsOtherQuality(self, nzb, movie_year = None, preferred_quality = None): + if not preferred_quality: preferred_quality = {} name = nzb['name'] size = nzb.get('size', 0) diff --git a/couchpotato/core/media/movie/_base/main.py b/couchpotato/core/media/movie/_base/main.py index a8d890d..4299f56 100644 --- a/couchpotato/core/media/movie/_base/main.py +++ b/couchpotato/core/media/movie/_base/main.py @@ -346,7 +346,8 @@ class MovieBase(MovieTypeBase): '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.' diff --git a/couchpotato/core/media/movie/library/movie/main.py b/couchpotato/core/media/movie/library/movie/main.py index 12e6abc..718e739 100644 --- a/couchpotato/core/media/movie/library/movie/main.py +++ b/couchpotato/core/media/movie/library/movie/main.py @@ -20,7 +20,9 @@ class MovieLibraryPlugin(LibraryBase): 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() diff --git a/couchpotato/core/notifications/base.py b/couchpotato/core/notifications/base.py index 61cb212..4c0d099 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) @@ -47,8 +49,8 @@ class Notification(Provider): 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 b30d487..0fca749 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 21cd197..a9a20b0 100644 --- a/couchpotato/core/notifications/core/main.py +++ b/couchpotato/core/notifications/core/main.py @@ -128,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() @@ -149,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') diff --git a/couchpotato/core/notifications/email/main.py b/couchpotato/core/notifications/email/main.py index 21fcf15..f94688d 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 caad661..dabeea0 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 43bb950..1479fb1 100644 --- a/couchpotato/core/notifications/nmj/main.py +++ b/couchpotato/core/notifications/nmj/main.py @@ -64,8 +64,9 @@ 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') diff --git a/couchpotato/core/notifications/notifo/main.py b/couchpotato/core/notifications/notifo/main.py index 6e4d7ad..2d56ed7 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 5e5bfb9..92e5956 100644 --- a/couchpotato/core/notifications/notifymyandroid/main.py +++ b/couchpotato/core/notifications/notifymyandroid/main.py @@ -8,7 +8,8 @@ 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')) diff --git a/couchpotato/core/notifications/plex/main.py b/couchpotato/core/notifications/plex/main.py index 9becfb5..f6088f5 100644 --- a/couchpotato/core/notifications/plex/main.py +++ b/couchpotato/core/notifications/plex/main.py @@ -17,8 +17,9 @@ 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 = self.getHosts(port = 32400) @@ -45,7 +46,8 @@ 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 = self.getHosts(port = 3000) successful = 0 diff --git a/couchpotato/core/notifications/prowl/main.py b/couchpotato/core/notifications/prowl/main.py index e5c4678..a8a3dda 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 4c5e76c..4e3b6e7 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 ea5e774..76f730b 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 3980430..0f7775d 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) diff --git a/couchpotato/core/notifications/toasty/main.py b/couchpotato/core/notifications/toasty/main.py index 79b021e..c65b6b4 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 86d4708..99d5553 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 4a34d60..ad4fc31 100644 --- a/couchpotato/core/notifications/twitter/main.py +++ b/couchpotato/core/notifications/twitter/main.py @@ -30,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')) diff --git a/couchpotato/core/notifications/xbmc/main.py b/couchpotato/core/notifications/xbmc/main.py index 836b40c..dc185c4 100755 --- a/couchpotato/core/notifications/xbmc/main.py +++ b/couchpotato/core/notifications/xbmc/main.py @@ -17,7 +17,8 @@ class XBMC(Notification): 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')) diff --git a/couchpotato/core/plugins/file/main.py b/couchpotato/core/plugins/file/main.py index cdd67f5..2f458f7 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/manage/main.py b/couchpotato/core/plugins/manage/main.py index f1ffefa..3a475b7 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) @@ -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] = { diff --git a/couchpotato/core/plugins/quality/main.py b/couchpotato/core/plugins/quality/main.py index 0e67a07..15710a8 100644 --- a/couchpotato/core/plugins/quality/main.py +++ b/couchpotato/core/plugins/quality/main.py @@ -152,7 +152,8 @@ 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])) diff --git a/couchpotato/core/plugins/renamer/main.py b/couchpotato/core/plugins/renamer/main.py index a45a265..4bbf351 100644 --- a/couchpotato/core/plugins/renamer/main.py +++ b/couchpotato/core/plugins/renamer/main.py @@ -495,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 = {} @@ -843,11 +845,12 @@ Remove it if you want it to be renamed (again, or at least let it try again) 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 = [], cleanup = False): + 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)$)' @@ -941,7 +944,7 @@ Remove it if you want it to be renamed (again, or at least let it try again) 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())) + 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: @@ -964,7 +967,7 @@ Remove it if you want it to be renamed (again, or at least let it try again) if extr_files: files.extend(extr_files) - # Cleanup files and folder if movie_folder was not provided + # Cleanup files and folder if movie_folder was not provided if not movie_folder: files = [] folder = None diff --git a/couchpotato/core/plugins/subtitle/main.py b/couchpotato/core/plugins/subtitle/main.py index 2aa22f4..e447dac 100644 --- a/couchpotato/core/plugins/subtitle/main.py +++ b/couchpotato/core/plugins/subtitle/main.py @@ -42,7 +42,6 @@ class Subtitle(Plugin): 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: diff --git a/couchpotato/core/plugins/trailer/main.py b/couchpotato/core/plugins/trailer/main.py index 1a8955f..e27e3f9 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/providers/info/couchpotatoapi/main.py b/couchpotato/core/providers/info/couchpotatoapi/main.py index cdbc513..ef7db1f 100644 --- a/couchpotato/core/providers/info/couchpotatoapi/main.py +++ b/couchpotato/core/providers/info/couchpotatoapi/main.py @@ -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/metadata/base.py b/couchpotato/core/providers/metadata/base.py index 7a65568..f561003 100644 --- a/couchpotato/core/providers/metadata/base.py +++ b/couchpotato/core/providers/metadata/base.py @@ -17,8 +17,9 @@ 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()) @@ -65,7 +66,8 @@ class MetaDataBase(Plugin): except: log.error('Unable to create %s file: %s', (file_type, traceback.format_exc())) - def getRootName(self, data = {}): + def getRootName(self, data = None): + if not data: data = {} return os.path.join(data['destination_dir'], data['filename']) def getFanartName(self, name, root): @@ -77,10 +79,13 @@ 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) file_type = {} @@ -102,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/xbmc/main.py b/couchpotato/core/providers/metadata/xbmc/main.py index 820df15..e865e2d 100644 --- a/couchpotato/core/providers/metadata/xbmc/main.py +++ b/couchpotato/core/providers/metadata/xbmc/main.py @@ -24,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/settings/__init__.py b/couchpotato/core/settings/__init__.py index b75e5dd..61d982f 100644 --- a/couchpotato/core/settings/__init__.py +++ b/couchpotato/core/settings/__init__.py @@ -72,7 +72,9 @@ 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(): diff --git a/couchpotato/core/settings/model.py b/couchpotato/core/settings/model.py index 2737392..f39544b 100644 --- a/couchpotato/core/settings/model.py +++ b/couchpotato/core/settings/model.py @@ -137,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 = {} @@ -200,7 +203,10 @@ 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 From 08f44197f3068fd54a08abc4e9e84e5d30b08a37 Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 3 Sep 2013 12:14:02 +0200 Subject: [PATCH 12/55] Use own cache --- couchpotato/core/providers/info/themoviedb/main.py | 104 +++++++++++---------- 1 file changed, 55 insertions(+), 49 deletions(-) diff --git a/couchpotato/core/providers/info/themoviedb/main.py b/couchpotato/core/providers/info/themoviedb/main.py index 2ec0e94..9eaf5f1 100644 --- a/couchpotato/core/providers/info/themoviedb/main.py +++ b/couchpotato/core/providers/info/themoviedb/main.py @@ -3,8 +3,6 @@ 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 -from couchpotato.environment import Env -import os import tmdb3 import traceback @@ -20,7 +18,7 @@ class TheMovieDb(MovieProvider): # Configure TMDB settings tmdb3.set_key(self.conf('api_key')) - tmdb3.set_cache(engine='file', filename=os.path.join(Env.get('cache_dir'), 'python', 'tmdb.cache')) + tmdb3.set_cache('null') def search(self, q, limit = 12): """ Find movie by name """ @@ -76,7 +74,7 @@ class TheMovieDb(MovieProvider): log.debug('Getting info: %s', cache_key) movie = tmdb3.Movie(identifier) result = self.parseMovie(movie) - self.setCache(cache_key, result) + self.setCache(md5(ss(cache_key)), result) except: pass @@ -84,52 +82,60 @@ class TheMovieDb(MovieProvider): def parseMovie(self, movie, with_titles = True): - # 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') + cache_key = 'tmdb.cache.%s' % movie.id + movie_data = self.getCache(cache_key) - # 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'])) + 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(md5(ss(cache_key)), movie_data) return movie_data From 97c456c9e1083a7d3d0d8d5cf5af3efcf9787fbd Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 3 Sep 2013 12:47:44 +0200 Subject: [PATCH 13/55] Optimize quality caching --- couchpotato/core/plugins/quality/main.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/couchpotato/core/plugins/quality/main.py b/couchpotato/core/plugins/quality/main.py index 15710a8..1149c03 100644 --- a/couchpotato/core/plugins/quality/main.py +++ b/couchpotato/core/plugins/quality/main.py @@ -156,36 +156,37 @@ class QualityPlugin(Plugin): 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()) found = {} - for quality in self.all(): + for quality in qualities: contains = self.containsTag(quality, words, cur_file) if contains: found[quality['identifier']] = True - for quality in self.all(): + for quality in qualities: # Check identifier if quality['identifier'] in words: 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(hash, quality) + return self.setCache(cache_key, quality) # Check alt and tags contains = self.containsTag(quality, words, cur_file) if contains: - return self.setCache(hash, quality) + 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 @@ -205,7 +206,7 @@ class QualityPlugin(Plugin): return - def guessLoose(self, hash, files = None, extra = None): + def guessLoose(self, cache_key, files = None, extra = None): if extra: for quality in self.all(): @@ -213,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 From 6af00bf0263633aa32376e6294cab26c213adb90 Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 3 Sep 2013 12:48:24 +0200 Subject: [PATCH 14/55] Standardize cache_key generation --- couchpotato/core/plugins/base.py | 7 ++++--- couchpotato/core/plugins/suggestion/main.py | 4 ++-- couchpotato/core/providers/info/themoviedb/main.py | 6 +++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/couchpotato/core/plugins/base.py b/couchpotato/core/plugins/base.py index 92a7efa..bd34270 100644 --- a/couchpotato/core/plugins/base.py +++ b/couchpotato/core/plugins/base.py @@ -259,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 @@ -284,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): diff --git a/couchpotato/core/plugins/suggestion/main.py b/couchpotato/core/plugins/suggestion/main.py index a7b640e..f922632 100644 --- a/couchpotato/core/plugins/suggestion/main.py +++ b/couchpotato/core/plugins/suggestion/main.py @@ -35,7 +35,7 @@ class Suggestion(Plugin): suggestions = cached_suggestion else: 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, @@ -87,6 +87,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/providers/info/themoviedb/main.py b/couchpotato/core/providers/info/themoviedb/main.py index 9eaf5f1..387355f 100644 --- a/couchpotato/core/providers/info/themoviedb/main.py +++ b/couchpotato/core/providers/info/themoviedb/main.py @@ -53,7 +53,7 @@ class TheMovieDb(MovieProvider): log.info('Found: %s', [result['titles'][0] + ' (' + str(result.get('year', 0)) + ')' for result in results]) - self.setCache(md5(ss(cache_key)), results) + self.setCache(cache_key, results) return results except SyntaxError, e: log.error('Failed to parse XML response: %s', e) @@ -74,7 +74,7 @@ class TheMovieDb(MovieProvider): log.debug('Getting info: %s', cache_key) movie = tmdb3.Movie(identifier) result = self.parseMovie(movie) - self.setCache(md5(ss(cache_key)), result) + self.setCache(cache_key, result) except: pass @@ -135,7 +135,7 @@ class TheMovieDb(MovieProvider): movie_data['titles'] = list(set(movie_data['titles'])) # Cache movie parsed - self.setCache(md5(ss(cache_key)), movie_data) + self.setCache(cache_key, movie_data) return movie_data From b5993bcc212558a5909a5035205bb6f8a141e9d9 Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 3 Sep 2013 19:14:59 +0200 Subject: [PATCH 15/55] NonBlock calls need to finish --- couchpotato/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/couchpotato/api.py b/couchpotato/api.py index e8970dc..e78f0f2 100644 --- a/couchpotato/api.py +++ b/couchpotato/api.py @@ -47,10 +47,10 @@ class NonBlockHandler(RequestHandler): return try: - self.write(response) + self.finish(response) except: log.error('Failed doing nonblock request: %s', (traceback.format_exc())) - self.write({'success': False, 'error': 'Failed returning results'}) + self.finish({'success': False, 'error': 'Failed returning results'}) def on_connection_close(self): From 4cfa79488f52790c75e73bc1835ae4a01341173b Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 3 Sep 2013 20:21:49 +0200 Subject: [PATCH 16/55] PublicHD cache description call --- .../core/providers/torrent/publichd/main.py | 23 ++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/couchpotato/core/providers/torrent/publichd/main.py b/couchpotato/core/providers/torrent/publichd/main.py index c93f5cd..7799b4f 100644 --- a/couchpotato/core/providers/torrent/publichd/main.py +++ b/couchpotato/core/providers/torrent/publichd/main.py @@ -68,14 +68,21 @@ class PublicHD(TorrentMagnetProvider): def getMoreInfo(self, item): - try: - 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 '' - except: - log.error('Failed getting more info for %s', item['name']) - description = '' + cache_key = 'publichd.%s' % item['id'] + description = self.getCache(cache_key) + + if not description: + + try: + full_description = self.urlopen(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 '' + except: + log.error('Failed getting more info for %s', item['name']) + description = '' + + self.setCache(cache_key, description) item['description'] = description return item From cd8d2d4808ee9c5b68c046996629d9d560375034 Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 3 Sep 2013 20:23:40 +0200 Subject: [PATCH 17/55] PublicHD description cache timeout --- couchpotato/core/providers/torrent/publichd/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/couchpotato/core/providers/torrent/publichd/main.py b/couchpotato/core/providers/torrent/publichd/main.py index 7799b4f..7b497fd 100644 --- a/couchpotato/core/providers/torrent/publichd/main.py +++ b/couchpotato/core/providers/torrent/publichd/main.py @@ -74,7 +74,7 @@ class PublicHD(TorrentMagnetProvider): if not description: try: - full_description = self.urlopen(item['detail_url'], cache_timeout = 25920000) + 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 '' @@ -82,7 +82,7 @@ class PublicHD(TorrentMagnetProvider): log.error('Failed getting more info for %s', item['name']) description = '' - self.setCache(cache_key, description) + self.setCache(cache_key, description, timeout = 25920000) item['description'] = description return item From 400fd461aba78df54a549fdaa87f08769d99020a Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 3 Sep 2013 21:12:22 +0200 Subject: [PATCH 18/55] Always add timestamp to registered statics --- couchpotato/core/_base/clientscript/main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/couchpotato/core/_base/clientscript/main.py b/couchpotato/core/_base/clientscript/main.py index 7476f39..efbaa64 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') @@ -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) From 8f88f7d89b795ad4f1daf25fec17284912738611 Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 3 Sep 2013 22:13:42 +0200 Subject: [PATCH 19/55] Javascript and css cleanup --- couchpotato/static/scripts/api.js | 6 +- couchpotato/static/scripts/block.js | 2 +- couchpotato/static/scripts/block/menu.js | 11 +- couchpotato/static/scripts/block/navigation.js | 5 +- couchpotato/static/scripts/couchpotato.js | 48 ++++--- couchpotato/static/scripts/page.js | 4 +- couchpotato/static/scripts/page/about.js | 4 +- couchpotato/static/scripts/page/home.js | 6 +- couchpotato/static/scripts/page/manage.js | 6 +- couchpotato/static/scripts/page/settings.js | 165 ++++++++++++------------- couchpotato/static/scripts/page/wanted.js | 4 +- couchpotato/static/style/api.css | 3 +- couchpotato/static/style/main.css | 10 +- couchpotato/static/style/settings.css | 18 ++- couchpotato/static/style/uniform.generic.css | 20 ++- 15 files changed, 145 insertions(+), 167 deletions(-) diff --git a/couchpotato/static/scripts/api.js b/couchpotato/static/scripts/api.js index 5e507bc..38d1874 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 82193ca..7407b7f 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 8d315f5..91e29a2 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 8389ff9..f5642df 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 fdc9bd1..dcd0f7b 100644 --- a/couchpotato/static/scripts/couchpotato.js +++ b/couchpotato/static/scripts/couchpotato.js @@ -15,7 +15,7 @@ var self = this; self.setOptions(options); - self.c = $(document.body) + self.c = $(document.body); self.route = new Route(self.defaults); @@ -48,7 +48,6 @@ }, 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'); @@ -111,11 +110,11 @@ 'click': self.shutdownQA.bind(self) } }) - ] + ]; setting_links.each(function(a){ self.block.more.addLink(a) - }) + }); new ScrollSpy({ @@ -133,7 +132,7 @@ 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); @@ -156,7 +155,7 @@ return; if(self.current_page) - self.current_page.hide() + self.current_page.hide(); try { var page = self.pages[page_name] || self.pages.Home; @@ -190,7 +189,7 @@ self.checkAvailable(1000); }, - shutdownQA: function(e){ + shutdownQA: function(){ var self = this; var q = new Question('Are you sure you want to shutdown CouchPotato?', '', [{ @@ -239,7 +238,7 @@ 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); @@ -257,7 +256,7 @@ }, 'onSuccess': function(){ if(onAvailable) - onAvailable() + onAvailable(); self.unBlockPage(); self.fireEvent('reload'); } @@ -271,7 +270,6 @@ self.unBlockPage(); - var body = $(document.body); self.mask = new Element('div.mask').adopt( new Element('div').adopt( new Element('h1', {'text': title || 'Unavailable'}), @@ -328,7 +326,7 @@ 'target': '', 'events': { 'click': function(e){ - (e).stop() + (e).stop(); alert('Drag it to your bookmark ;)') } } @@ -351,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 } }) @@ -487,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; } @@ -529,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 1af800e..58ba5ac 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 ba451c8..f931335 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 01344ad..93d0435 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){ @@ -110,7 +110,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 }, @@ -139,4 +139,4 @@ Page.Home = new Class({ } -}) \ 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 36b1ef8..4827f51 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(); @@ -108,7 +108,7 @@ Page.Manage = new Class({ 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 f760754..68b41d0 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]) + if(!self.tabs[group.tab].subtabs[group.subtab]) self.createSubTab(group.subtab, group, self.tabs[group.tab], group.tab); - var content_container = self.tabs[group.tab].subtabs[group.subtab].content + 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.subtab_label || 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; @@ -330,10 +324,11 @@ var OptionBase = new Class({ */ createBase: function(){ var self = this; - self.el = new Element('div.ctrlHolder.'+self.section + '_' + self.name) + 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 6adffbd..98a676c 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,7 +35,7 @@ Page.Wanted = new Class({ }, - doFullSearch: function(full){ + doFullSearch: function(){ var self = this; if(!self.search_in_progress){ diff --git a/couchpotato/static/style/api.css b/couchpotato/static/style/api.css index c635409..0c9f0f0 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 ae461c4..e79dbea 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 132d9c5..61d5239 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%; } @@ -689,7 +686,6 @@ .group_userscript .bookmarklet { display: block; - display: block; float: left; padding: 20px 15px 0 25px; border-radius: 5px; diff --git a/couchpotato/static/style/uniform.generic.css b/couchpotato/static/style/uniform.generic.css index e70a915..8ac4136 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; } /* ----------------------------------------------------------------------------- */ From 7f304b0c285f15cf5ebcb5adf3ea271d1e3fef25 Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 3 Sep 2013 22:50:27 +0200 Subject: [PATCH 20/55] Don't load profile on movie list --- couchpotato/core/media/movie/_base/main.py | 2 -- couchpotato/core/media/movie/_base/static/movie.actions.js | 2 +- couchpotato/core/media/movie/_base/static/search.js | 8 ++++---- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/couchpotato/core/media/movie/_base/main.py b/couchpotato/core/media/movie/_base/main.py index 4299f56..2354d5a 100644 --- a/couchpotato/core/media/movie/_base/main.py +++ b/couchpotato/core/media/movie/_base/main.py @@ -208,7 +208,6 @@ class MovieBase(MovieTypeBase): q2 = db.query(Movie).join((q, q.c.id == Movie.id)) \ .options(joinedload_all('releases.files')) \ .options(joinedload_all('releases.info')) \ - .options(joinedload_all('profile.types')) \ .options(joinedload_all('library.titles')) \ .options(joinedload_all('library.files')) \ .options(joinedload_all('status')) \ @@ -224,7 +223,6 @@ class MovieBase(MovieTypeBase): movies = [] for movie in results: movies.append(movie.to_dict({ - 'profile': {'types': {}}, 'releases': {'files':{}, 'info': {}}, 'library': {'titles': {}, 'files':{}}, 'files': {}, diff --git a/couchpotato/core/media/movie/_base/static/movie.actions.js b/couchpotato/core/media/movie/_base/static/movie.actions.js index ea6f00f..0dbea2c 100644 --- a/couchpotato/core/media/movie/_base/static/movie.actions.js +++ b/couchpotato/core/media/movie/_base/static/movie.actions.js @@ -581,7 +581,7 @@ 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); }); diff --git a/couchpotato/core/media/movie/_base/static/search.js b/couchpotato/core/media/movie/_base/static/search.js index 376e61c..7332381 100644 --- a/couchpotato/core/media/movie/_base/static/search.js +++ b/couchpotato/core/media/movie/_base/static/search.js @@ -326,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), @@ -390,7 +390,7 @@ 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 || in_library)) + !(self.info.in_wanted && self.info.in_wanted.profile_id || in_library)) self.add(); } From ec302fe66553b4186263f331b47da361e9bc223c Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 4 Sep 2013 13:46:51 +0200 Subject: [PATCH 21/55] Make sure that a faulty api call end after error --- couchpotato/api.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/couchpotato/api.py b/couchpotato/api.py index e78f0f2..a9f449b 100644 --- a/couchpotato/api.py +++ b/couchpotato/api.py @@ -95,8 +95,12 @@ class ApiHandler(RequestHandler): # Add async callback handler @run_async def run_handler(callback): - result = api[route](**kwargs) - callback(result) + 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 From 47141f8e4ff07f7fcf23167ef978c313e7a012da Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 4 Sep 2013 22:01:50 +0200 Subject: [PATCH 22/55] Api: added release.for_movie Get all releases for a single movie --- couchpotato/core/plugins/release/main.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/couchpotato/core/plugins/release/main.py b/couchpotato/core/plugins/release/main.py index b2cc4e5..fb3750b 100644 --- a/couchpotato/core/plugins/release/main.py +++ b/couchpotato/core/plugins/release/main.py @@ -6,6 +6,7 @@ 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 @@ -36,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) @@ -204,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']['score'], reverse = True) + + return { + 'releases': releases, + 'success': True + } + From 0c5b950c87d63375b1bdeede31e8ffff34264232 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 4 Sep 2013 22:03:03 +0200 Subject: [PATCH 23/55] Add manual to tryNextRelease --- couchpotato/core/media/movie/searcher/main.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/couchpotato/core/media/movie/searcher/main.py b/couchpotato/core/media/movie/searcher/main.py index 18b8064..fdf3460 100644 --- a/couchpotato/core/media/movie/searcher/main.py +++ b/couchpotato/core/media/movie/searcher/main.py @@ -115,7 +115,7 @@ class MovieSearcher(SearcherBase, MovieTypeBase): self.in_progress = False - def single(self, movie, search_protocols = None): + def single(self, movie, search_protocols = None, manual = False): # Find out search type try: @@ -126,7 +126,7 @@ class MovieSearcher(SearcherBase, MovieTypeBase): 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 @@ -237,7 +237,7 @@ class MovieSearcher(SearcherBase, MovieTypeBase): log.info('Ignored, score to low: %s', nzb['name']) continue - downloaded = fireEvent('searcher.download', data = nzb, movie = movie, single = True) + downloaded = fireEvent('searcher.download', data = nzb, movie = movie, manual = manual, single = True) if downloaded is True: ret = True break @@ -403,7 +403,7 @@ class MovieSearcher(SearcherBase, MovieTypeBase): def tryNextReleaseView(self, id = None, **kwargs): - trynext = self.tryNextRelease(id) + trynext = self.tryNextRelease(id, manual = True) return { 'success': trynext @@ -411,14 +411,14 @@ class MovieSearcher(SearcherBase, MovieTypeBase): 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') @@ -426,7 +426,7 @@ class MovieSearcher(SearcherBase, MovieTypeBase): movie_dict = fireEvent('movie.get', movie_id, single = True) log.info('Trying next release for: %s', getTitle(movie_dict['library'])) - fireEvent('movie.searcher.single', movie_dict) + fireEvent('movie.searcher.single', movie_dict, manual = manual) return True From ac9aaec7b8eee6b44b8306127269ede1c0f3c959 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 4 Sep 2013 22:03:39 +0200 Subject: [PATCH 24/55] Optimize movie.list --- couchpotato/core/media/movie/_base/main.py | 47 +++++-- .../core/media/movie/_base/static/movie.actions.js | 148 ++++++++++++++------- couchpotato/core/media/movie/_base/static/movie.js | 6 +- 3 files changed, 136 insertions(+), 65 deletions(-) diff --git a/couchpotato/core/media/movie/_base/main.py b/couchpotato/core/media/movie/_base/main.py index 2354d5a..e71dde0 100644 --- a/couchpotato/core/media/movie/_base/main.py +++ b/couchpotato/core/media/movie/_base/main.py @@ -2,7 +2,8 @@ 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, tryInt +from couchpotato.core.helpers.variable import getImdb, splitString, tryInt, \ + mergeDicts from couchpotato.core.logger import CPLog from couchpotato.core.media.movie import MovieTypeBase from couchpotato.core.settings.model import Library, LibraryTitle, Movie, \ @@ -204,28 +205,50 @@ class MovieBase(MovieTypeBase): 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.files')) \ - .options(joinedload_all('releases.info')) \ + 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) + + + 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 = [] for movie in results: - movies.append(movie.to_dict({ - 'releases': {'files':{}, 'info': {}}, + + releases = [] + for r in release_statuses.get(movie.id): + x = splitString(r) + releases.append({'status_id': x[0], 'quality_id': x[1]}) + movies.append(mergeDicts(movie.to_dict({ 'library': {'titles': {}, 'files':{}}, 'files': {}, + }), { + 'releases': releases, + 'releases_count': releases_count.get(movie.id), })) db.expire_all() diff --git a/couchpotato/core/media/movie/_base/static/movie.actions.js b/couchpotato/core/media/movie/_base/static/movie.actions.js index 0dbea2c..29a8a62 100644 --- a/couchpotato/core/media/movie/_base/static/movie.actions.js +++ b/couchpotato/core/media/movie/_base/static/movie.actions.js @@ -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', { @@ -247,19 +289,16 @@ MA.Release = new Class({ })] : null ) } + + 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; + + 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(self.next_release || (self.last_release && ['ignored', 'failed'].indexOf(self.last_release.status.identifier) === false)){ + }); + + 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') + } + }); } diff --git a/couchpotato/core/media/movie/_base/static/movie.js b/couchpotato/core/media/movie/_base/static/movie.js index 20956a0..962b46a 100644 --- a/couchpotato/core/media/movie/_base/static/movie.js +++ b/couchpotato/core/media/movie/_base/static/movie.js @@ -58,7 +58,7 @@ var Movie = new Class({ }) }, - busy: function(set_busy){ + busy: function(set_busy, timeout){ var self = this; if(!set_busy){ @@ -72,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(); From f4d5366c93351fa08a6b90ff2960b8e4335f067e Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 4 Sep 2013 22:20:55 +0200 Subject: [PATCH 25/55] Remove profile from dashboard list --- couchpotato/core/plugins/dashboard/main.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/couchpotato/core/plugins/dashboard/main.py b/couchpotato/core/plugins/dashboard/main.py index b02e5d9..18b5226 100644 --- a/couchpotato/core/plugins/dashboard/main.py +++ b/couchpotato/core/plugins/dashboard/main.py @@ -46,7 +46,6 @@ class Dashboard(Plugin): q = db.query(Movie).join((subq, subq.c.id == Movie.id)) \ .options(joinedload_all('releases')) \ - .options(joinedload_all('profile')) \ .options(joinedload_all('library.titles')) \ .options(joinedload_all('library.files')) \ .options(joinedload_all('status')) \ @@ -65,7 +64,7 @@ class Dashboard(Plugin): movies = [] for movie in all_movies: - pp = profile_pre.get(movie.profile.id) + pp = profile_pre.get(movie.profile_id) eta = movie.library.info.get('release_date', {}) or {} coming_soon = False @@ -86,8 +85,6 @@ class Dashboard(Plugin): if coming_soon: temp = movie.to_dict({ - 'profile': {'types': {}}, - 'releases': {'files':{}, 'info': {}}, 'library': {'titles': {}, 'files':{}}, 'files': {}, }) From 88d512eaccb8339c02b62d936196b00b49c208c4 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 4 Sep 2013 22:21:13 +0200 Subject: [PATCH 26/55] Don't try to use releases when there aren't any --- couchpotato/core/media/movie/_base/static/movie.js | 29 +++++++++++----------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/couchpotato/core/media/movie/_base/static/movie.js b/couchpotato/core/media/movie/_base/static/movie.js index 962b46a..363d860 100644 --- a/couchpotato/core/media/movie/_base/static/movie.js +++ b/couchpotato/core/media/movie/_base/static/movie.js @@ -179,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) From a6ce114284ec80a566c18fb65f24e1402b4b56b0 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 4 Sep 2013 22:26:37 +0200 Subject: [PATCH 27/55] Optimize suggestion listing --- couchpotato/core/plugins/suggestion/main.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/couchpotato/core/plugins/suggestion/main.py b/couchpotato/core/plugins/suggestion/main.py index f922632..6492f6b 100644 --- a/couchpotato/core/plugins/suggestion/main.py +++ b/couchpotato/core/plugins/suggestion/main.py @@ -1,13 +1,13 @@ 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.sql.expression import or_ + class Suggestion(Plugin): def __init__(self): @@ -15,32 +15,32 @@ 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) \ + .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('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): From b11e1d48e0222522b9cca701fbf173b0dffc1dd1 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 4 Sep 2013 22:29:05 +0200 Subject: [PATCH 28/55] Suggestion listing: load library in single query --- couchpotato/core/plugins/suggestion/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/couchpotato/core/plugins/suggestion/main.py b/couchpotato/core/plugins/suggestion/main.py index 6492f6b..d6fdeb4 100644 --- a/couchpotato/core/plugins/suggestion/main.py +++ b/couchpotato/core/plugins/suggestion/main.py @@ -5,6 +5,7 @@ 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_ @@ -28,6 +29,7 @@ class Suggestion(Plugin): 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] From 5c61c24c04f931f19f79141233368452cb5aa798 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 4 Sep 2013 22:39:42 +0200 Subject: [PATCH 29/55] Lazyload file list in manage tab --- .../core/media/movie/_base/static/movie.actions.js | 37 +++++++++++++++++++--- couchpotato/core/plugins/release/main.py | 2 +- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/couchpotato/core/media/movie/_base/static/movie.actions.js b/couchpotato/core/media/movie/_base/static/movie.actions.js index 29a8a62..7d8c37f 100644 --- a/couchpotato/core/media/movie/_base/static/movie.actions.js +++ b/couchpotato/core/media/movie/_base/static/movie.actions.js @@ -289,7 +289,7 @@ MA.Release = new Class({ })] : null ) } - + self.last_release = null; self.next_release = null; @@ -828,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') @@ -850,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/release/main.py b/couchpotato/core/plugins/release/main.py index fb3750b..eb60728 100644 --- a/couchpotato/core/plugins/release/main.py +++ b/couchpotato/core/plugins/release/main.py @@ -223,7 +223,7 @@ class Release(Plugin): .all() releases = [r.to_dict({'info':{}, 'files':{}}) for r in releases_raw] - releases = sorted(releases, key = lambda k: k['info']['score'], reverse = True) + releases = sorted(releases, key = lambda k: k['info'].get('score', 0), reverse = True) return { 'releases': releases, From 7714504831f31921b391d7c0b1e280411afa1f34 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 4 Sep 2013 23:20:03 +0200 Subject: [PATCH 30/55] Run dashboard calls serial --- .../core/plugins/suggestion/static/suggest.js | 2 + couchpotato/static/scripts/page/home.js | 78 ++++++++++++++++------ 2 files changed, 59 insertions(+), 21 deletions(-) diff --git a/couchpotato/core/plugins/suggestion/static/suggest.js b/couchpotato/core/plugins/suggestion/static/suggest.js index 16feca9..817d965 100644 --- a/couchpotato/core/plugins/suggestion/static/suggest.js +++ b/couchpotato/core/plugins/suggestion/static/suggest.js @@ -88,6 +88,8 @@ var SuggestList = new Class({ $(m).inject(self.el); }); + + self.fireEvent('loaded'); }, diff --git a/couchpotato/static/scripts/page/home.js b/couchpotato/static/scripts/page/home.js index 93d0435..75601df 100644 --- a/couchpotato/static/scripts/page/home.js +++ b/couchpotato/static/scripts/page/home.js @@ -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, @@ -61,7 +85,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 +126,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({ @@ -118,24 +165,13 @@ 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); } From 117b952455c15abe272d7985ed814dbd8e11d62a Mon Sep 17 00:00:00 2001 From: Ruud Date: Thu, 5 Sep 2013 21:46:00 +0200 Subject: [PATCH 31/55] Default back to type on protocol. fix #2120 --- couchpotato/core/plugins/base.py | 2 +- couchpotato/core/plugins/release/main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/couchpotato/core/plugins/base.py b/couchpotato/core/plugins/base.py index bd34270..0c023a8 100644 --- a/couchpotato/core/plugins/base.py +++ b/couchpotato/core/plugins/base.py @@ -295,7 +295,7 @@ class Plugin(object): def createFileName(self, data, filedata, movie): name = os.path.join(self.createNzbName(data, movie)) - if data.get('protocol') == '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('protocol')) diff --git a/couchpotato/core/plugins/release/main.py b/couchpotato/core/plugins/release/main.py index eb60728..6916b96 100644 --- a/couchpotato/core/plugins/release/main.py +++ b/couchpotato/core/plugins/release/main.py @@ -182,7 +182,7 @@ class Release(Plugin): # Get matching provider provider = fireEvent('provider.belongs_to', item['url'], provider = item.get('provider'), single = True) - if item['protocol'] != '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({ From 23f77df911c9c55ea538cd9dee37099edfc9f43c Mon Sep 17 00:00:00 2001 From: Ruud Date: Fri, 6 Sep 2013 00:23:52 +0200 Subject: [PATCH 32/55] Optimize profile queries --- couchpotato/core/plugins/profile/main.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/couchpotato/core/plugins/profile/main.py b/couchpotato/core/plugins/profile/main.py index 27963fb..68ab936 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() From c41b3a612a2a2c3855a22b6305ba944cddcf2821 Mon Sep 17 00:00:00 2001 From: Ruud Date: Fri, 6 Sep 2013 00:24:17 +0200 Subject: [PATCH 33/55] Optimize dashboard soon listing --- couchpotato/core/plugins/dashboard/main.py | 108 ++++++++++++++++------------- 1 file changed, 60 insertions(+), 48 deletions(-) diff --git a/couchpotato/core/plugins/dashboard/main.py b/couchpotato/core/plugins/dashboard/main.py index 18b5226..589b81e 100644 --- a/couchpotato/core/plugins/dashboard/main.py +++ b/couchpotato/core/plugins/dashboard/main.py @@ -40,64 +40,76 @@ 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('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) + active = db.query(Movie) \ + .filter(Movie.status_id == active_status.get('id')) \ + .all() + all_movie_ids = [r.id for r in active] + # Do the shuffle if random: - rndm.shuffle(all_movies) + rndm.shuffle(all_movie_ids) + group_limit = limit * 5 + group_offset = 0 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('movie.searcher.could_be_released', True, eta, movie.library.year, single = True): - coming_soon = True - if pp.get('dvd') and fireEvent('movie.searcher.could_be_released', False, eta, movie.library.year, single = True): - coming_soon = True - - # 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 - - if coming_soon: - temp = movie.to_dict({ - 'library': {'titles': {}, 'files':{}}, - 'files': {}, - }) - - # 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) - - if len(movies) >= limit: - break - - db.expire_all() + + while group_offset < len(all_movie_ids) and len(movies) < limit: + + movie_ids = all_movie_ids[group_offset:group_offset + group_limit] + group_offset += group_limit + + # Only joined needed + q = db.query(Movie) \ + .options(joinedload_all('library')) \ + .filter(Movie.id.in_(movie_ids)) + all_movies = q.all() + + for movie in all_movies: + pp = profile_pre.get(movie.profile_id) + if not pp: continue + + eta = movie.library.info.get('release_date', {}) or {} + coming_soon = False + + # Theater quality + if pp.get('theater') and fireEvent('movie.searcher.could_be_released', True, eta, movie.library.year, single = True): + coming_soon = True + elif pp.get('dvd') and fireEvent('movie.searcher.could_be_released', False, eta, movie.library.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))): + movies.append(movie.id) + + if len(movies) >= limit: + break + + # 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_(movies)) \ + .all() + + movies = [] + for r in movies_raw: + movies.append(r.to_dict({ + 'library': {'titles': {}, 'files':{}}, + 'files': {}, + })) + return { 'success': True, 'empty': len(movies) == 0, From 59a718be201a427c32caeaf58f5ff53911756b2c Mon Sep 17 00:00:00 2001 From: Ruud Date: Fri, 6 Sep 2013 00:41:15 +0200 Subject: [PATCH 34/55] Optimize events with single handler --- couchpotato/core/event.py | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/couchpotato/core/event.py b/couchpotato/core/event.py index 30c5189..7b01fbd 100644 --- a/couchpotato/core/event.py +++ b/couchpotato/core/event.py @@ -55,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: @@ -69,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 @@ -80,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: + + 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) + # Fire + result = e(*args, **kwargs) if options['single'] and not options['merge']: results = None From 347125365f0754bfe6f9c3d2dbeb2e31e58d03ff Mon Sep 17 00:00:00 2001 From: Ruud Date: Fri, 6 Sep 2013 19:19:20 +0200 Subject: [PATCH 35/55] movie.list didn't keep order --- couchpotato/core/media/movie/_base/main.py | 42 +++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/couchpotato/core/media/movie/_base/main.py b/couchpotato/core/media/movie/_base/main.py index e71dde0..f1c56a7 100644 --- a/couchpotato/core/media/movie/_base/main.py +++ b/couchpotato/core/media/movie/_base/main.py @@ -168,19 +168,33 @@ class MovieBase(MovieTypeBase): 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()) @@ -195,7 +209,7 @@ class MovieBase(MovieTypeBase): 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() @@ -211,7 +225,7 @@ class MovieBase(MovieTypeBase): 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 @@ -236,19 +250,27 @@ class MovieBase(MovieTypeBase): results = q2.all() - movies = [] + # Create dict by movie id + movie_dict = {} for movie in results: + 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): + for r in release_statuses.get(movie_id): x = splitString(r) releases.append({'status_id': x[0], 'quality_id': x[1]}) - movies.append(mergeDicts(movie.to_dict({ + + # 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), + 'releases_count': releases_count.get(movie_id), })) db.expire_all() From bc94e909941f298cd0bcee11a21a423ab938db15 Mon Sep 17 00:00:00 2001 From: Ruud Date: Fri, 6 Sep 2013 19:37:39 +0200 Subject: [PATCH 36/55] Optimize available char listing --- couchpotato/core/media/movie/_base/main.py | 43 ++++++++++++++++------- couchpotato/core/media/movie/_base/static/list.js | 32 +++++++++-------- 2 files changed, 47 insertions(+), 28 deletions(-) diff --git a/couchpotato/core/media/movie/_base/main.py b/couchpotato/core/media/movie/_base/main.py index f1c56a7..1c7eb31 100644 --- a/couchpotato/core/media/movie/_base/main.py +++ b/couchpotato/core/media/movie/_base/main.py @@ -278,7 +278,8 @@ class MovieBase(MovieTypeBase): def availableChars(self, status = None, release_status = None): - chars = '' + status = status or [] + release_status = release_status or [] db = get_session() @@ -288,28 +289,44 @@ class MovieBase(MovieTypeBase): 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): diff --git a/couchpotato/core/media/movie/_base/static/list.js b/couchpotato/core/media/movie/_base/static/list.js index 1b11fab..5c883b1 100644 --- a/couchpotato/core/media/movie/_base/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){ From 1b6bf13619536f8888ffc4d13bb91a81f9d84dcc Mon Sep 17 00:00:00 2001 From: Ruud Date: Fri, 6 Sep 2013 20:03:34 +0200 Subject: [PATCH 37/55] Optimize and order dashboard list --- couchpotato/core/plugins/dashboard/main.py | 80 +++++++++++++++--------------- 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/couchpotato/core/plugins/dashboard/main.py b/couchpotato/core/plugins/dashboard/main.py index 589b81e..5a35167 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 @@ -48,64 +49,65 @@ class Dashboard(Plugin): # Get all active movies active_status = fireEvent('status.get', ['active'], single = True) - active = db.query(Movie) \ + q = db.query(Movie) \ + .join(Library) \ .filter(Movie.status_id == active_status.get('id')) \ - .all() - all_movie_ids = [r.id for r in active] - - # Do the shuffle - if random: - rndm.shuffle(all_movie_ids) + .with_entities(Movie.id, Movie.profile_id, Library.info, Library.year) \ + .group_by(Movie.id) - group_limit = limit * 5 - group_offset = 0 - movies = [] + if not random: + q = q.join(LibraryTitle) \ + .filter(LibraryTitle.default == True) \ + .order_by(asc(LibraryTitle.simple_title)) - while group_offset < len(all_movie_ids) and len(movies) < limit: + active = q.all() - movie_ids = all_movie_ids[group_offset:group_offset + group_limit] - group_offset += group_limit + # Do the shuffle + if random: + rndm.shuffle(active) - # Only joined needed - q = db.query(Movie) \ - .options(joinedload_all('library')) \ - .filter(Movie.id.in_(movie_ids)) - all_movies = q.all() + movie_ids = [] + for movie in active: + movie_id, profile_id, info, year = movie - for movie in all_movies: - pp = profile_pre.get(movie.profile_id) - if not pp: continue + pp = profile_pre.get(profile_id) + if not pp: continue - eta = movie.library.info.get('release_date', {}) or {} - coming_soon = False + eta = info.get('release_date', {}) or {} + coming_soon = False - # Theater quality - if pp.get('theater') and fireEvent('movie.searcher.could_be_released', True, eta, movie.library.year, single = True): - coming_soon = True - elif pp.get('dvd') and fireEvent('movie.searcher.could_be_released', False, eta, movie.library.year, single = True): - coming_soon = True + # 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: + 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))): - movies.append(movie.id) + # 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(movies) >= limit: - break + if len(movie_ids) >= limit: + break # 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_(movies)) \ + .filter(Movie.id.in_(movie_ids)) \ .all() + # Create dict by movie id + movie_dict = {} + for movie in movies_raw: + movie_dict[movie.id] = movie + movies = [] - for r in movies_raw: - movies.append(r.to_dict({ + for movie_id in movie_ids: + movies.append(movie_dict[movie_id].to_dict({ 'library': {'titles': {}, 'files':{}}, 'files': {}, })) From 203a52bfd13fb1d0cf26b7b37c020e65630d8ee9 Mon Sep 17 00:00:00 2001 From: Ruud Date: Fri, 6 Sep 2013 20:17:21 +0200 Subject: [PATCH 38/55] Don't load updater.js twice --- couchpotato/core/_base/updater/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/couchpotato/core/_base/updater/main.py b/couchpotato/core/_base/updater/main.py index 38b7d36..f3b4b19 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 From 226cf6fc381e18f48424ead7a2e19f100a879aef Mon Sep 17 00:00:00 2001 From: Ruud Date: Fri, 6 Sep 2013 22:45:37 +0200 Subject: [PATCH 39/55] Make sure to not query db when there aren't any ids --- couchpotato/core/media/movie/_base/main.py | 2 + couchpotato/core/plugins/dashboard/main.py | 82 ++++++++++++++++-------------- 2 files changed, 45 insertions(+), 39 deletions(-) diff --git a/couchpotato/core/media/movie/_base/main.py b/couchpotato/core/media/movie/_base/main.py index 1c7eb31..d5d41cc 100644 --- a/couchpotato/core/media/movie/_base/main.py +++ b/couchpotato/core/media/movie/_base/main.py @@ -213,6 +213,8 @@ class MovieBase(MovieTypeBase): 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)) diff --git a/couchpotato/core/plugins/dashboard/main.py b/couchpotato/core/plugins/dashboard/main.py index 5a35167..2da4d8c 100644 --- a/couchpotato/core/plugins/dashboard/main.py +++ b/couchpotato/core/plugins/dashboard/main.py @@ -61,56 +61,60 @@ class Dashboard(Plugin): .order_by(asc(LibraryTitle.simple_title)) active = q.all() + movies = [] - # Do the shuffle - if random: - rndm.shuffle(active) + if len(active) > 0: - movie_ids = [] - for movie in active: - movie_id, profile_id, info, year = movie + # Do the shuffle + if random: + rndm.shuffle(active) - pp = profile_pre.get(profile_id) - if not pp: continue + movie_ids = [] + for movie in active: + movie_id, profile_id, info, year = movie - eta = info.get('release_date', {}) or {} - coming_soon = False + pp = profile_pre.get(profile_id) + if not pp: continue - # 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 + eta = info.get('release_date', {}) or {} + coming_soon = False - if coming_soon: + # 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 - # 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 coming_soon: - if len(movie_ids) >= limit: - break + # 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) - # 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() + if len(movie_ids) >= limit: + break - # Create dict by movie id - movie_dict = {} - for movie in movies_raw: - movie_dict[movie.id] = movie + if len(movie_ids) > 0: - movies = [] - for movie_id in movie_ids: - movies.append(movie_dict[movie_id].to_dict({ - 'library': {'titles': {}, 'files':{}}, - 'files': {}, - })) + # 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': {}, + })) return { 'success': True, From 38886b28f716d21e99fd0f6374b48ca8809ad5af Mon Sep 17 00:00:00 2001 From: Ruud Date: Fri, 6 Sep 2013 23:05:41 +0200 Subject: [PATCH 40/55] Hide soon and late blocks on dashboard if their empty. fix #1778 --- couchpotato/core/media/movie/_base/static/list.js | 6 +++--- couchpotato/static/scripts/page/home.js | 4 ---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/couchpotato/core/media/movie/_base/static/list.js b/couchpotato/core/media/movie/_base/static/list.js index 5c883b1..341d234 100644 --- a/couchpotato/core/media/movie/_base/static/list.js +++ b/couchpotato/core/media/movie/_base/static/list.js @@ -285,11 +285,11 @@ var MovieList = new Class({ }, self.filter), 'onSuccess': function(json){ available_chars = json.chars - + json.chars.split('').each(function(c){ self.letters[c.capitalize()].addClass('available') }) - + } }); }); @@ -568,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/static/scripts/page/home.js b/couchpotato/static/scripts/page/home.js index 75601df..b93db5b 100644 --- a/couchpotato/static/scripts/page/home.js +++ b/couchpotato/static/scripts/page/home.js @@ -74,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 }, From 52a0de3b594de7ba198c29cbd7672a5f7f3f23eb Mon Sep 17 00:00:00 2001 From: Ruud Date: Fri, 6 Sep 2013 23:12:22 +0200 Subject: [PATCH 41/55] Deleting from late block didn't work --- couchpotato/core/media/movie/_base/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/couchpotato/core/media/movie/_base/main.py b/couchpotato/core/media/movie/_base/main.py index d5d41cc..e6f66e2 100644 --- a/couchpotato/core/media/movie/_base/main.py +++ b/couchpotato/core/media/movie/_base/main.py @@ -581,7 +581,7 @@ class MovieBase(MovieTypeBase): 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 From df13a0edc29cf4eba6f232370009968b7110c377 Mon Sep 17 00:00:00 2001 From: Ruud Date: Sun, 8 Sep 2013 22:12:08 +0200 Subject: [PATCH 42/55] Ignore modules with only .pyc files in them. --- couchpotato/core/loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/couchpotato/core/loader.py b/couchpotato/core/loader.py index f101105..2016d28 100644 --- a/couchpotato/core/loader.py +++ b/couchpotato/core/loader.py @@ -91,7 +91,7 @@ class Loader(object): 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)) and name != 'static': + 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) From 1aa26a5a6ce72edd8e36aabb0aefd0c622be5cfd Mon Sep 17 00:00:00 2001 From: Ruud Date: Mon, 9 Sep 2013 22:28:21 +0200 Subject: [PATCH 43/55] Replace protocol if it doesn't exist --- couchpotato/core/media/_base/searcher/main.py | 6 +++++- couchpotato/core/plugins/base.py | 2 +- couchpotato/core/plugins/release/main.py | 6 +++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/couchpotato/core/media/_base/searcher/main.py b/couchpotato/core/media/_base/searcher/main.py index 89afd75..f09be64 100644 --- a/couchpotato/core/media/_base/searcher/main.py +++ b/couchpotato/core/media/_base/searcher/main.py @@ -51,6 +51,10 @@ class Searcher(SearcherBase): def download(self, data, movie, manual = False): + if not data.get('protocol'): + data['protocol'] = data['type'] + data['type'] = 'movie' + # Test to see if any downloaders are enabled for this type downloader_enabled = fireEvent('download.enabled', manual, data, single = True) @@ -122,7 +126,7 @@ class Searcher(SearcherBase): return True - log.info('Tried to download, but none of the "%s" downloaders are enabled or gave an error', (data.get('protocol', ''))) + log.info('Tried to download, but none of the "%s" downloaders are enabled or gave an error', (data.get('protocol'))) return False diff --git a/couchpotato/core/plugins/base.py b/couchpotato/core/plugins/base.py index 0c023a8..bd34270 100644 --- a/couchpotato/core/plugins/base.py +++ b/couchpotato/core/plugins/base.py @@ -295,7 +295,7 @@ class Plugin(object): def createFileName(self, data, filedata, movie): name = os.path.join(self.createNzbName(data, movie)) - if data.get('protocol', data.get('type')) == 'nzb' and 'DOCTYPE nzb' not in filedata and '' not in filedata: + if data.get('protocol') == 'nzb' and 'DOCTYPE nzb' not in filedata and '' not in filedata: return '%s.%s' % (name, 'rar') return '%s.%s' % (name, data.get('protocol')) diff --git a/couchpotato/core/plugins/release/main.py b/couchpotato/core/plugins/release/main.py index 6916b96..72089cc 100644 --- a/couchpotato/core/plugins/release/main.py +++ b/couchpotato/core/plugins/release/main.py @@ -182,7 +182,11 @@ class Release(Plugin): # Get matching provider provider = fireEvent('provider.belongs_to', item['url'], provider = item.get('provider'), single = True) - if item.get('protocol', item.get('type')) != 'torrent_magnet': + if not item.get('protocol'): + item['protocol'] = item['type'] + item['type'] = 'movie' + + if item.get('protocol') != '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({ From 94647bbb5712dd3d25595c92a5592b7ae56f192d Mon Sep 17 00:00:00 2001 From: Mythin Date: Mon, 9 Sep 2013 23:08:49 -0700 Subject: [PATCH 44/55] Fix the variable passed to the getImdb method --- couchpotato/core/plugins/scanner/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/couchpotato/core/plugins/scanner/main.py b/couchpotato/core/plugins/scanner/main.py index 188924a..3221ac7 100644 --- a/couchpotato/core/plugins/scanner/main.py +++ b/couchpotato/core/plugins/scanner/main.py @@ -565,7 +565,7 @@ class Scanner(Plugin): if not imdb_id: try: for nf in files['nfo']: - imdb_id = getImdb(nfo_file) + imdb_id = getImdb(nf) if imdb_id: log.debug('Found movie via nfo file: %s', nf) nfo_file = nf From 9783409756f7d068f349a5b58549f1c55d0d4d43 Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 10 Sep 2013 18:02:04 +0200 Subject: [PATCH 45/55] Login base --- couchpotato/__init__.py | 57 +++++++++++++++++++++++++++++++++++----- couchpotato/core/auth.py | 45 ------------------------------- couchpotato/runner.py | 9 +++++-- couchpotato/templates/login.html | 18 +++++++++++++ 4 files changed, 76 insertions(+), 53 deletions(-) delete mode 100644 couchpotato/core/auth.py create mode 100644 couchpotato/templates/login.html diff --git a/couchpotato/__init__.py b/couchpotato/__init__.py index 089ecd4..930e465 100644 --- a/couchpotato/__init__.py +++ b/couchpotato/__init__.py @@ -1,11 +1,10 @@ from couchpotato.api import api_docs, api_docs_missing, api -from couchpotato.core.auth import requires_auth from couchpotato.core.event import fireEvent -from couchpotato.core.helpers.variable import md5 +from couchpotato.core.helpers.variable import md5, tryInt from couchpotato.core.logger import CPLog from couchpotato.environment import Env from tornado import template -from tornado.web import RequestHandler +from tornado.web import RequestHandler, authenticated import os import time import traceback @@ -16,9 +15,23 @@ log = CPLog(__name__) views = {} template_loader = template.Loader(os.path.join(os.path.dirname(__file__), 'templates')) + +class BaseHandler(RequestHandler): + + def get_current_user(self): + username = Env.setting('username') + password = Env.setting('password') + + if username or password: + print self.get_secure_cookie('user') + return self.get_secure_cookie('user') + else: # Login when no username or password are set + return True + # Main web handler -@requires_auth -class WebHandler(RequestHandler): +class WebHandler(BaseHandler): + + @authenticated def get(self, route, *args, **kwargs): route = route.strip('/') if not views.get(route): @@ -28,7 +41,7 @@ class WebHandler(RequestHandler): try: self.write(views[route]()) except: - log.error('Failed doing web request "%s": %s', (route, traceback.format_exc())) + 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): @@ -79,6 +92,38 @@ class KeyHandler(RequestHandler): self.write({'success': False, 'error': 'Failed returning results'}) +class LoginHandler(BaseHandler): + + def get(self, *args, **kwargs): + + if self.get_current_user(): + self.redirect('/') + else: + self.write(template_loader.load('login.html').generate()) + + def post(self, *args, **kwargs): + + api = None + + username = Env.setting('username') + password = Env.setting('password') + + if (self.get_argument('username') == username or not username) and (md5(self.get_argument('password')) == password or not password): + api = Env.setting('api_key') + + if api: + remember_me = tryInt(self.get_argument('remember_me', default = 0)) + self.set_secure_cookie('user', api, expires_days = 30 if remember_me else 0) + + self.redirect('/') + +class LogoutHandler(BaseHandler): + + def get(self, *args, **kwargs): + self.clear_cookie('user') + self.redirect('/login') + + def page_not_found(rh): index_url = Env.get('web_base') url = rh.request.uri[len(index_url):] diff --git a/couchpotato/core/auth.py b/couchpotato/core/auth.py deleted file mode 100644 index e877860..0000000 --- a/couchpotato/core/auth.py +++ /dev/null @@ -1,45 +0,0 @@ -from couchpotato.core.helpers.variable import md5 -from couchpotato.environment import Env -import base64 - -def check_auth(username, password): - return username == Env.setting('username') and password == Env.setting('password') - -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) - - if auth_header is None or not auth_header.startswith('Basic ') or (not check_auth(username.decode('latin'), md5(password.decode('latin')))): - handler.set_status(401) - handler.set_header('WWW-Authenticate', 'Basic realm="CouchPotato Login"') - handler._transforms = [] - handler.finish() - - return False - - return True - - def _execute(self, transforms, *args, **kwargs): - - if not require_basic_auth(self, kwargs): - return False - return handler_execute(self, transforms, *args, **kwargs) - - return _execute - - handler_class._execute = wrap_execute(handler_class._execute) - - return handler_class diff --git a/couchpotato/runner.py b/couchpotato/runner.py index ab92919..19ccf4e 100644 --- a/couchpotato/runner.py +++ b/couchpotato/runner.py @@ -1,6 +1,6 @@ from argparse import ArgumentParser from cache import FileSystemCache -from couchpotato import KeyHandler +from couchpotato import KeyHandler, LoginHandler, LogoutHandler from couchpotato.api import NonBlockHandler, ApiHandler from couchpotato.core.event import fireEventAsync, fireEvent from couchpotato.core.helpers.encoding import toUnicode @@ -233,10 +233,11 @@ def runCouchPotato(options, base_path, args, data_dir = None, log_dir = None, En log_function = lambda x : None, debug = config['use_reloader'], gzip = True, + cookie_secret = api_key, + login_url = "/login", ) Env.set('app', application) - # Request handlers application.add_handlers(".*$", [ (r'%snonblock/(.*)(/?)' % api_base, NonBlockHandler), @@ -245,6 +246,10 @@ def runCouchPotato(options, base_path, args, data_dir = None, log_dir = None, En (r'%s(.*)(/?)' % api_base, ApiHandler), # Main API handler (r'%sgetkey(/?)' % web_base, KeyHandler), # Get API key (r'%s' % api_base, RedirectHandler, {"url": web_base + 'docs/'}), # API docs + + # Login handlers + (r'%slogin(/?)' % web_base, LoginHandler), + (r'%slogout(/?)' % web_base, LogoutHandler), # Catch all webhandlers (r'%s(.*)(/?)' % web_base, WebHandler), diff --git a/couchpotato/templates/login.html b/couchpotato/templates/login.html new file mode 100644 index 0000000..d06440c --- /dev/null +++ b/couchpotato/templates/login.html @@ -0,0 +1,18 @@ +{% autoescape None %} + + + + + + + Login + + +
+
+
+
+
+
+ + \ No newline at end of file From 4a71f2c55636227323fc9c7da80c305efe31bcb6 Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 10 Sep 2013 22:58:41 +0200 Subject: [PATCH 46/55] Login styling --- couchpotato/__init__.py | 11 +- couchpotato/static/fonts/Lobster-webfont.eot | Bin 0 -> 29745 bytes couchpotato/static/fonts/Lobster-webfont.svg | 244 ++++++++++++++++++++++++++ couchpotato/static/fonts/Lobster-webfont.ttf | Bin 0 -> 65192 bytes couchpotato/static/fonts/Lobster-webfont.woff | Bin 0 -> 33756 bytes couchpotato/static/style/main.css | 86 ++++++++- couchpotato/templates/login.html | 32 +++- 7 files changed, 358 insertions(+), 15 deletions(-) create mode 100755 couchpotato/static/fonts/Lobster-webfont.eot create mode 100755 couchpotato/static/fonts/Lobster-webfont.svg create mode 100755 couchpotato/static/fonts/Lobster-webfont.ttf create mode 100755 couchpotato/static/fonts/Lobster-webfont.woff diff --git a/couchpotato/__init__.py b/couchpotato/__init__.py index 930e465..04756fa 100644 --- a/couchpotato/__init__.py +++ b/couchpotato/__init__.py @@ -23,7 +23,6 @@ class BaseHandler(RequestHandler): password = Env.setting('password') if username or password: - print self.get_secure_cookie('user') return self.get_secure_cookie('user') else: # Login when no username or password are set return True @@ -97,9 +96,9 @@ class LoginHandler(BaseHandler): def get(self, *args, **kwargs): if self.get_current_user(): - self.redirect('/') + self.redirect(Env.get('web_base')) else: - self.write(template_loader.load('login.html').generate()) + self.write(template_loader.load('login.html').generate(sep = os.sep, fireEvent = fireEvent, Env = Env)) def post(self, *args, **kwargs): @@ -113,15 +112,15 @@ class LoginHandler(BaseHandler): if api: remember_me = tryInt(self.get_argument('remember_me', default = 0)) - self.set_secure_cookie('user', api, expires_days = 30 if remember_me else 0) + self.set_secure_cookie('user', api, expires_days = 30 if remember_me > 0 else None) - self.redirect('/') + self.redirect(Env.get('web_base')) class LogoutHandler(BaseHandler): def get(self, *args, **kwargs): self.clear_cookie('user') - self.redirect('/login') + self.redirect('%slogin/' % Env.get('web_base')) def page_not_found(rh): diff --git a/couchpotato/static/fonts/Lobster-webfont.eot b/couchpotato/static/fonts/Lobster-webfont.eot new file mode 100755 index 0000000000000000000000000000000000000000..56f66aaec596be9522fd6476f1bb070cda1d8118 GIT binary patch literal 29745 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 literal 0 HcmV?d00001 diff --git a/couchpotato/static/fonts/Lobster-webfont.svg b/couchpotato/static/fonts/Lobster-webfont.svg new file mode 100755 index 0000000..e445583 --- /dev/null +++ b/couchpotato/static/fonts/Lobster-webfont.svg @@ -0,0 +1,244 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/couchpotato/static/fonts/Lobster-webfont.ttf b/couchpotato/static/fonts/Lobster-webfont.ttf new file mode 100755 index 0000000000000000000000000000000000000000..4c46e93f1f56ef70738b4914bdb91e5cb113bf08 GIT binary patch literal 65192 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

qHGQ-IO} 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 QIUbEwnAC5-?0by;Ki#y;U;qFB literal 0 HcmV?d00001 diff --git a/couchpotato/static/fonts/Lobster-webfont.woff b/couchpotato/static/fonts/Lobster-webfont.woff new file mode 100755 index 0000000000000000000000000000000000000000..af59caade46465d65ec6064b1960f50c1045e069 GIT binary patch literal 33756 zcmY&<$X&Pg(vGnph; z#zRp;0tguBCxmPOq5c?kK71#};DrN9VtC{sWSQt(W-^Hwy&hvJM1v6!SeSuVrawYzhSAUGl>IK!%VB=4)x^>iNTc{N%6uQ>UV!Swsq32f&Y>pVm*glRsEIej$3ZGxq!` zH-PNNj_5ytK!8}=8{3)vaAH5UKXrsKD5M%na&&NU1p*5C&-V0(-=}MZjC6E1`>_rA z(F2P6K|hwn9-D`m$xod_7Jp=j|KK;I!uS&d{{U!`zhLIScvKAy?7w(gok7fV4Pazy zWV8cn!VQ6B1R^34Mk(}>vJ0Lt1%d$p0-`wsx%@wCn4jlH00eAn>}vnhI{&l({{Al) zARy1RoP-W*5Mi{i9&K?`WY~Hq^jl~#I=V*mQ}L$Fre7JEuzE|Sz#9xYZKY6F!Y06dn9rA-|KJZ^z$)@fhXwxHA^^8W zaIh55rr$>9sGe*eyV*JW7&b%Ehchd7d(QEXY?vkH|I)j@p?xF}tiT_E{w`xF(0ZM(T_TKZDvNVMVf8MF91tYy+-Z-I#Sn29z(9l>6?x-c zcVUZm8*+cmM(toDxrVg$j^~MZXvgv93zxJMWySI^7a)$~LnZ&$RsbU-BlAEbYzPSm zBu|h7=HOR4ILg3Af)J2|AxP6(mPO_&z{?EQq*kA6%xN4*dT>N=O7QRCq~NUJ?%-6B z&VC&6Vo10i?XPdh>_OPYwm@T00FoMiNN`~AuV6T}E^>;N_v*E=T%7kg(%SG%W&m;2|(gh*Iucqlj+1jyLv_^7y;c?qMlYs+)&R&N{P zu$YYce{F{ef3?SU~ z71#&va^T0jh}`X`Hj9SStRNBf+r00Wo6dQ$5b(@BUBCMlEa1>N?OEA8I3Hb~9u-e= zSW=DeF1Q=8;_$$&D!@#Drp=AumhJF5kLk}*+nwav29zt1hIwwXu0TT)ZiNUYcV(0%U zlFEi4#+33E5gkMHGz2=d>6h8G+G0wM+4d<)q) z9X#@rN#!6Eaxxwa_u;!1BmhhoDwU$(rR1AhODHeR1c z54J4N%V^jd7P1Mi33VW}n23b>YZ%T<7ahcb%KMB^O5ovbd_WFV#}+3sdt0v?h(*j0@wjzKwtLE`kqfg*+ua{lF2G;^&11cx%>?Gco9COv!h&7sH+_IZop8bf>;ej?6~t`xObyZ-)0oR62Fd6F4HhvJ zvOGEFWT+Mm(ZjByfQoYROe%NlkVF;~BGAgW(kdOd^|OKJgtCvhQ|d|$0^P2xjovQT zdfe|VVSmBnMHW|lXbAg+$jB^Hgstoy#XD${vs=ZZMhuCO5wegsP4cwxp4C|`i6p)G z!~=w@fc=YP&Mv34ZAFBM2f~l5!*s(VMN0$I@eG*xUQ-!kdTh;l%1pT^Ilgn(^c3~> zWR%_Ezg1J`m9CUx0=b93?qE8rc86JXLdX-PfP#m$nk5Tafgl`3zgo54yr%*A9w*3T z_#4SvOcZC}bB6(&X|x&w)N#jpb{G5lm3q6FHRa0kAxa^ zPd24V5wSfL)R%(%ZcPCFF5W3TtY0xXHmTLi;k_G9+elog*p};7|IDtjNj~O%CY$*n zemdXIeIMXGx5FUFt@L=^Q5LF%C4|e&E#{CCQNdOgIIUt2m)(BWEqtq zDEU`{ot$gGw8|(4xqO@!MBLZf=QP=&9iEWhZ3-hmHxCzOQFHS#v;hzJbK6P zH6^CC`3c>Td^gb1ddI*ZHFd*Ka3exF3W&sS#I#@ydP>ULcO;;x zD9)l=g36EtvMHa0wus@#VF;0(f4z444_L*|`R7&?O&%dXu@98ye>;J)})O*HO*-m2tNI))SE<@svs> zQ2X58^m$KXI~ROC3*m_Z9vzy;H6tZ_MXO-w9QtoMeC!oI{v&ha?Tfd{?N z25DgTA@VE0n4#`SPh$WD3=H7A#WD$efjRPh_5wzjRUk$$#zwO7m)I^&T+1mHuBGBY zbE^sYL>xo5Pqyd_!7Jp)Ouqf$K0B!O{b_Tn!B*f43`rG``R)Hq3x2C(VFgt}qEY+k zLgN9;ai}Ao z$;%feN;livItpygcM9OAhV`4ow}y>Q$EGUtPJiE4+IaBcWIiudM#dRVZ{4v2^lj@; zbe?vehN_nsPEx}-u}KKwc4F13~F%`2!$jdYPrm;2R#!f;Sjt5@1S9efq*b)-a9 zC|EbRUtURuXH4lV>)CE(BdBKcIB3x7=JF6YQ~y%8EKAu2>tY&9ub!1MY-+ma`L29^ zu3UY+DvoK6QK&H6nMxV>pXMFJ9}avk`m?rSd<`EGPQY~_eubfb|98ZF#TK-VVt$1Q zeM79M!1M@Y(KL5}rOh>Mdz{uVDz-hXjT~zF)5XIjj;s?VbLAxqL^Ij<)*r|2Fpi`x-rD||fGc5Lx{ zXdKrDY(g{g`JBuyK}M?jKkqDsBiM0MeMhz;^l$$g=NsD`!Z(+j1clRbm3EM!aQOwG z*?rQ6$FdL0-!+dhYK4!4Grqy!g^MyYj*H$n=ici5*EfyjsX#bTJb*igHG_SUX{tQ+ z_~)K~@dYQ8&gMYq9`{$(aBoQH(mrx+_6<+E8M}9K#qjAkNO=(h&^K&xGY5*@8DjOX z_zBFL$A@)HhEBg*lBz=Gv@x0aF)pkd6}XC#V#!>mVR7IM`T;G8+5Rb35I`U~6osgq zv+{{b#oF8%36*&M9%*c4$%A~)F@#S(jZ*X5?w2}jY0c}(Ynq87Nyt+E!2#ug zojWR%dElM|8{owOBpL^n4u*DCeBj^@(d!r>zHi3e@GAo^$cLTjR(fl9&;CBy)T`m_UoZU-~?vNmVtapBy`@2~`fyH(-1;hA2r{nKEKcS%E}n=ygGPB&$$B zBI%b(zPWUaR)Esq{Sb$73okFTl{Hwtllb}xfSv>?YuDtaTnXf(Bu=E^05f?nl!4g7 zAgid*?>M%uJgC-xagCVr@`ieJ{`l;Hm{eBiWc)c?{DFw<3|e>`lSiUPYD@&+dgJpBRGi;c&c zr=5*@DN2(h$QAi{cB|uhiL8pXNDW`h2ipqtppHuX4-b1{7x zy$MIlVj&Bg_b_1NjXLGW7iEt4@|P#^4LeiEBv;nyrSyBxI;rtpajl`A*=3L0>& z|C2Tl^X~RZ8^aU2Hp-0+YUqBMop_4_{TZ|VQQ%D zlLc3-@{Q^&U9+6D7Z$uTKNl~vm?i*>c1r7Pj`)rfcpgL#L)I2svy4XnCTK&)I;lKw zbx0rDJvz_2(7B*h4f4nxBH3G zLfHO{Lz%Acei8wT^En{9y4F7PHJTTpb9u+0dB1&ec3eCJK3YyybF%AmU6F9(&N^U< zwIiH>tG#Uj4VMc>D;UWIQ>cW0QZrKydOVm{dMeX0oa%3xm<6!vkrV|vL<%02a6;64 zYuO)jgkXqzutaB2(6n-tyV+M(8CNBeh-T&36AZC;s?ISOa*o*td*XA)b>UmgpYFxy zhG!(A+50ZWsDU?%A6zmw(N!Et(FqD)1YI3ZO1~F*I3fy`>(`#$goT#oCXdI<{QjuN66kW4cTIQmZ7Y|AkpOF+9aSZz@vy+z`&^3H5P z7OCO8eoD7rLM|$pwZG|K=6NK!)8&d%EK69%o2YV$p{jZcjGyL*Kc`>MC$d%0T^!^L zdtQf+b7MBx?9`V|DPQs5y$=+lB;!#X$xU#8+VDUP&Hz`YEIF3=H0%Iuevq@B zOfV?3zs;(MMig^e^IYl`K*W_uMKnxO)i6obNa`jZ)Ktqd5yx_&FRB$rRDu!e%~gfO ztC2#`?$Qq8`PQ^{Pk4jk@YN?Qyh%iX6qO|I)J)WikK(J=f4H>WcrLjdAHfntZ)x+v z3ownQ7VLfZI9u%a>rABc`ORr$DZJO)lif4`P5@Q6>s@dW)yI;|CB;kpf6M!ZPrcjr z`hF_~7?9j^T2K7*vN7B1jZQMw8P7{YB{vEI*L(YlAEz&2M}H2R()?;|t?)AMQk&z3 ze0Y{GPutBdZq=I>YF2}=o1FR~$BOHf3+cDW6^GYqJ~RT@cc@A>YP{SOrQT4vr`&_J!M^shk1* z>$ZC%YHdP=DNAX9Ct?UP2FxOvRtw59%~B#AWh=J+!{Js_c9lqA-~r2dRH0ZMtBI_F zN3|)Q%L4eqKR{H55;krU=esm7OY-!gy%u`LBuoDsg*$Y#A?bJt0Wgf_7>pO&F6NgW z-%nrr@6Ys|&*?s#eD&n=6&7%MZjCJ-SGh+HMLNs2Hr84V=^OWE+=-nQTeF+Ikhwgb zk8RY64tBb)^W~4K9(j#)TW@E#;_=PRd~aCqbG?7oa$PFDkN8SXQrvrwTZB3k%n6gN zB)?Wbzd1?1JE*1FXaamlZvxQo0yOoieDqCGEQ2mA>(KleQOS6rM8`*8f{r`pZY-HH z=MY-|*=hT=Rv;r8718i#IXiNYwGh5+5x}ezQ?Z@Q(@>N|x0LGK<)-wX(^r_3L29nV zO->u@fyB%S*mTu)Ok?M+_ta6E?aC(MyLEBGVawuxj(3gP6}JHdC0ML)Aa_Ap6+OcknBKbC@~~%-q0HZK zRs}~G?FRQN9eU(*zdx_hePEtlc>)w~vR|a1i!wbGH;dwW?bJQTI_~UwU;llcN@r~h`v9v?K-dA|mM7+6+*%oa45w@UD1V2XnXw5L;HwiuwKi^d? zf*w6lpJoe2gJ^T6abAkSfuyrf1GrHss$ij6Hjko~sr-r#@sKEOVP;g8*+F+gW1-(c z2&Z@**Qg^(Z0fZhwGxlD>FKf?uv3*W5tUtG$UNlJ+2r(bH*`}JT?w;nbXBmMVPQ|4 z>B?L4SNHII+j!U@rIj>Ca5%MHZ#KP6LicGeg|EsqU^S^I*N@_ZY3Cg18o37EWC>|x zVlzs>aRXoz^k?d&7U*B_TM_6P@HREEL8ms{O~Snlvcd}{@*%Bw_Jw3iN2=6%c0UFm zfEl%;Wwe8pBW}H>diaz7icZiHsAi7l;WPo`!>FzOGBa{<_?%eWlrp6bcizUQXJ_^< zm+r@nXH5ERiU33Z`^u4cL!X`&k~P(Z6Bhxe^%(uMuc)!vK-`jaeL)5_c?6c; znCtbOe8Kj$mZ{YGhkhB$$qkYh8pDCd76fd+LZUs`fH8B>Xuz6WmQm=c>%;zA*-Zo# zY0>ZLp;uN3 zWgeal?ikPp6jVn;D4CDdiy3nCS&Q_Z-t2D!xM#O{x}?wk`($G?(6zZcZ{;_2e-;&> zwDn%}At=opS<5lg?e{Wzd+I^BI%`br9sNF4lDE}TWj0i^g>F(w+0t`-u;QR?S$593%(pq`Y439Xa(3AxounM7xU&1ELtztwLTo;DjI4B~9yG;YhrU<&|Ug3)}*&q+`4 zInUW6@DOg8#Cep%|2%ME*D&EzliO`6+5nK$wHEr~ZTr@Hk4j97s;<q2sbr4dHL{${p)Sy7&lz_Cb}h4^idF}SR#(BOtO8jnR7n^&&XjP>1JDrqor9)P zl;S;V%yCyFUc@O@71*%ZG;}cS4FBiSQclcL^;4%bB`P0qVB2S221&#PNo?X!y7^!( zxWg5+ZDUeMEckyB4#{|j;*`CPhx3rHLp;9kgpQ-azl_)AA#GtQeN)b|+#O#RcglIc zslNH|#vM36r~D=~_-~_eXGRT^w|%_Np_qnCTQm&sCkZnq-VFt4^S*QXaRM_R?{qi3 z=x5kiI9W4!25CS#jnz;ErDwfGa*$U^g;z<%Y*@7IpbqQdNaAM6B%~|6@FQzMy_{D6 zO89V_!A0|vs;yALp#nc=?M_tMpv8?@(wprsVY;45ZKY2%#O)y+Tt?_dXUZ?Pi>mP+;IID&5$x?k!XCVkahR%BBXxJO`agAqhA#Z#ku&lMi9h^wNI zgfq_t+6U$Ubo$^OQ&X&e&c-ju#d3#`C3mVNcbdUlG>@bT1a)xgnq+)de>S61-FK?0 zCicPNX!=M;A{1uj6f~u^iEZ{l%#mpqRJ6;J+L_P>V+y$k(L5FVs4!>2g^mx<>lguCMiq$& z`>Abx?1*bOWK?V44nB@jPLv}gV%~ECF*IaGM+u~Rzt+pqLM*be*vwLS**ru#vwpBg zOu$`gE$bADUuBBU-D(g2)ff0xgwW`&uXh%VnMV`~ghx!Q4kD;dvkEf?oX5471ikpC zhu&`U^?_SVLIC20hjmyildghHi1fEF2nmP_{^x_FfHY>5%Tp> ziQbfBT+6+&m&a1s!}{AgZdF3($sK)@sHPgQ-F4-`DA;P+$zb`o1kUF$-X?kN<3z#T zZ;Tk%F~8z?8)sR8u^8E(1;i zV8JBs``!)8z^?9tI_Az%2lCKtBqPU{3M8m<#V#&IZQYb^+tdcxVBkt@6!)H#mNHiY zr{H3TZj>H|V%brL_PYDnjPk=sNijz*`GiexXeY5HW`@=7S_Kko+ClD4ZrXEWuPow#Vs_E)=2`n?WSidGz1p~2#A5?b>%+6UYTFj|C z3d)FFBLkim_nZWZ&+G?a01M-f2S9J?da~cX)X>*CrmfZEn&7hYnAs}CdzOxYtR?SNX zeT_ZRH)!?19nFiCDLDu=^Av4`tKCKtCDepj?LE=urlg1$tA!s#W9PL{Pl8s?KDZ@) zx|&G_jZlYuMhsS2Re(aznzdWeSacT~ry7{i`}h0A$|ZRVfJjX3nE5jdm$kSS?6|Ix zjJN;x4ysIdM?eVkuwCR!_m})LwN&>3OZv_-^4?SO0@gD>`RCj}d`!%-*vvS!gS;Bs za~&ndh{{5EL`d5kzN#fGsk($<6r)`DZ)i}ejU{OK0TBBk(x8c&ssPvXP_fB=>-Bf) z{S2*GER+da1dc|l?w=;=N?1@#3)BPd$IuTILn2 z9!q%Lt81$}{kIBrpI1>!6C2_JynZ(@Y;vcQeM>Q18r5#Ligi~O`j?QqcBbRy`G^GE z?tvi-Uzka-R(YjEvv!~dz%?j~`^wF+n!TEXB;b)qq+GBI84CZYg#xY=r{sMw!KU)R zE-T{n1NjRQOmf-!DPiE@?`Nd@A&G!-;&g z>!T1B2DdG6NrHLe9LtOhvNib^0ky@AgqAIJlLC3;`j8Bh8GJ6LCEVoWT99g0#=quO z{>3pFjOA^)0S`_|Axa)0_N4u)FIvT*_ZQLmmz~K~r#n#;KT-etI$tIKQXPL8m%3BK zT>F!UI|~PM2EnWM2QvY;z7c*;C)DTBp~onC;G8nAw7nbBrL+wC#WbanUP~E@Z`QDZ zGOW1ZCeM?P7D_ExHjL%DhQhY^tHDZ~ITc29?MKHc8tkgP2ydjO>i|b|e~MFh@3}Hd z4OEF8yyPWZv`XRJIo!!)x(H_6mPdPA9QaHMtId+wkr{igjjxy5Ia^=f zBM&0d#KMXgIN#T&YQJv9fKkTI-uqj{8LQ?N46)#4SAj)WPzm};9SXq2ifb891+LgzZi8Zwe>_I)F*wvb!ZJkojvR$d26l3yUGXJj9~$*zk_2C6a75FwOlb zd-200plh*=jTyZiWal0R1@0qv;{!3gr>CpD%C7J0J>c)BuHaouZQIoxd3g!BVztAy z)^`hJ)B^6Y-t*duW_C=i^YIng#)|o-+f%zZW2f&^q+f`jzh57%L-$A1QZvTu`dxFa zJ+D+S*?WWfyXHt(TxVy?p8}Cx#4m9zpA0V@?GCE4tbft6-+br^P3lB8P~JLA{UwY| zs1&t0-M(M6FdqLb_0@!!$1tD~F8GK38}XcIQG!{DBc)O28hyo#Cqb%Cbz-_zVoqfT z)rYB}>E2QOWfm#)ZknEyTg>PDdaP;oq73jDY&47 zq#C|BkXs9r8Z?eFV9Z~UojbpT96m)a3Zym+O;@4WpA1`3>zdjE>_ksg(y`-%idv4q zbBrDJ9wMfJqF@M#$V~of2&0&#!f&>K2QKNRSqcERz>Y+KC9VjLwXWS^<)kRmxk%=U z&98;KLFcl6atZH0t>mHqx(VwmNi?mVXg{csz8FE|WXSG5+=7N1W$7+&>id`A@f(#e zDK9zCaC0&BU}c7`HZ!;8-k>if$1HAneYEYHJBXICi?7F1=n?g$6!_WvXgfm=sE^_4 z`16;J5bp(SKCE71N4W9{;j(NART>pvON|4t(+LtOwS)qB>MuM*5q-*Yj1j~UsI(tgM{0Y>RfbHzK1fOCOPSF0Zl15a%i=nmAWq-G7Ec zw^d2|_8hlWSFhN0vR<=j=_=5E^cZCL_`b#EjdKTi`=_q~dKp zK62AKBz#}ybjIhjf8g|>S+azB1&_Is%3lxx9ZDr}B@C9GGPE4{sQ;b2a)x7jx5_Gz z8k@wRW|IQqMu`fw1C_Q!9h4dD))*UG8FopPc`~8#$DAFvx*z~>mH@Bo-PG{u;5#Tg zSO5Dn(Yl7TY{{8_+Nb8Vkw#r9cc6;Zn7dn2RkJ96F05JRbvH&G>nz_rm}PCy$*rDi zUu|k5Su(^?{m=Kj!0#Ocd0lY;^c*5PZALDWVJEvrnH!Pf=o~a}{r4HAZw&6sNhIj5~cR8MJb|oXrtT>K)3?Bg*(WxdG@?mXRt& zmYr)f^~zSj7n<8*S$Q)}a~5*-&folUarOx*b6{b*TZKvAKj9EkVsncaEXBz8H-N#% z4SKTXvlEHS&~ty99{AtrbeZjTpD9#_<@NO2ewNaF6oyqDH?&ied(CFgHNsok5a$4f zyp{I3>7;78lB7F?QgbSqiCJW2I6G3h@CsOg(le53tFL7b5V1Zkpxo@mVKn|7b&f>yVa2KCT$6i zY3aLLP<;Kh)5~fr1M67)%kuzy0#--AVDjjomy)4&R|tRFZq8D1RUNhx)|-jjF3Oys z?Ffr4wgtem=lL1D1nLXl6^fyk8~jmE?MM8_akoUZ8MTUXWo=P?1jd0#6bGrWUDQI6 zSv4O$@GW&jTXluH(IvIyR&>_)Vfxmp>iV7`l5A=k&Xt@i0*8m2Iss)6;BZRS!nXBv zv3(`CVHq6}_btzRjHGku)4cC=ouednWg_7Hg|V411M{n<&3JX?>w(N?pjh4=EL#e(>^mlR5iHKBw`xu0^w=BjU!t9qhW>W@+Zs9N*&_S;52rnt%Ylz_-P* z@2_CxhG&{rwXF-<=kn{2cRE0gvL9)*b2JK~e}i{}fp-H#t2SnrcKS%5k6m4#@O-c_ z=E>Ba1Sp*pwmhJKTo^V^iwxz_^PJ=2eHJ_VB6p0Fux{=>{M!&4vmYlSlP}=8xF>OD zOmpJs2Q zSN2|@jta$6gGG`8N4JOV8tx=bCo&WLs`4b9X5^; zn%Kk<%Z>@Gr58;1Eh%^-T_IE&aI%isN;?k)+ z8l;T7&YAs48EY;(3nRVK-T~_I^-PZp2B8>`8Fk%5U|)FZ(RE1cER1z6mDHNS?=u zzVSRGvs({vA@kxe{AXfL&JjdTd8f(&R-fqnXVNG-o=K+H3nn3NgH6>`4{pa$`bnb{ zXb!^JYM9i{ku&;~x`;7h*^q8^Kl5(9n&iXAcIb0CV-Jr&wlsgarvN<(1*7B-VSJw( zfOx#^&T|76r*&q#w2#(Fo*Irx>unfTq*0e%atZ%)oRV5YkST!k=_D80!Wu-hzjG50K+(9k@{$y2&mY@C_gfEr7RvD6=@En7_lV{gktDvVcHGb%v!hA zxNwYVLTZY+I_Hfln`>SNn8ordvH`LGa(X_9S4zDyoS1|MgYywBm|~0<0&z5@WsoDmq1u!UV2qU038$<YEMl?&k^mZ_R3-j%(8k$NfEmJ4lll)c;}vBU&9G*k?HEeq(XeNjV+cZQ0i7MayQ zY3UX^t6L%=ULpmKD^4|S><<$gjaZJO!Wa&DBRyULFHt4czXl1y&9YvpCgGRCJdAAH z{MzWvG;502Z~Sl4BnXh_sk0?6Rz$Ro{Kq{rP#v zQ~|7)pH7JN2Z6M}Gt99(>xE{1RLy zZ&WmiVW->v86^Jgdl2iFC0#Yw!=R@qG4;KWu=Y;!%vU;Y&B<2d=vK3 z%VehZ$J)jYDy?ZN~nhB(M+Y*9bV_X z2HIBT#v#l?a{&>2o=j(J%TOE_3RIp7!niC$DML}?vQ0G9)wB?oYJ?qUJ&KnxS(pFq zV*DllLi$X#u1kwMyCG)8lvCIhqxa6H@M}O#n}tQcVCmnBEdIM%mFV@XPlm6pSI(I- z#&#=tK0S4`s^4uNw~2}~qooc!J2gJ84u?8n9Usk;H|Ko<;+YU7>1eEtVx&X83}H7# zp}2*-IxPO};tfVMJnC&1h0d#%*b^G;D1LOBHN6G|GwRd{ipsb-Lj`2vQy_ zIUlA#2mXxLZ-6%juC8w`LW1kFl06vRY?RAjGYJK#MJ>Q$I_H zU5LR#sHFyC6UU@`z;r`Oh9X5y2E%qxlt$yEcelJKm~eg}jUWd55lJA;1Y#8IjJ+!9 zs_$NrX1>7oA1pKpZ`Pp*T>^2$e?3t`5a>!sot_eN_A6^~S!5R-x}_aiEbeHr=G!F= z-6G1W4D~-^k;~Gg1iY1UYYmi`6Q**CTf1{b+jgr>spH0q(4aO}%tvwiwFY1FdzSm0 ztemBEhO0HNO%yLf=iPpFn{GBKhzs!w&=3tr(w zj-h@Tv*wIYl)Vn~k~Zs{DRgjjhJTGETrc4TRd}yPbi!AC7~nP1R=_wBYTJEU`@BiN zTi5(%9Pe}@z~9(rf&&h~ao;4DM4dw}{=VV}>Z3wz8SAAP>SJ8$j&q}HyUjBNF-(Qa zif%Vl7-{FJO3gr<>2%cb+$1Sd5 z5FOi36uw|q3t>)ED zoX8;iiCU%jRhz&@JohPxPn~s!#wUub1m^%6HO8GWOTB|&lEyvh*ayDerAg;hFBC|> zO2`)c)3ekVSXaEN0=>RMqQ0UZ01=mmURe>WrH-CoQ3jz$VkKQR*j4FWoxWzhW#4ez zShE);C0s=$riN7;VM#fm!D*%J{H4}PKtgGd$jJt+Tf5W|>rxg&pV8LJ>baxQATPMs z{b7yvmrHuAhaGQSO|^}fcv7#``m(FxY-;7&);3!eTc1;Pd7eulwJBV9nPfjnLd?Th z(-B$*+5YYDx9dXfo{(Z1rZ7WC`MrbLsbtAI%rj+#N*-&+>SZ+Y{a2dJITJMRLL76%wZK*S12z3 z`s30)<6NbBuogdzAftptDl`4)x8v`fU*>I6ZiRcnNgw5B!5TqVvvvPM8Pilb8%J}Q zQ$ov3%13aDS5Ijq)Ad90K=ko}@vB=teP~#BEXTr$1{{(sZ^PbMzVNrV8XYc8!Fd+h zW?V%4@BQt>!3UivJ_$4q{mevSsd7<_8QC$^)p||6NKc>zc+zlH?zx?o%i?yX|#x=;`Ibq z+m^|A3hnP3(K(I7B;ned1CRo(+a(X9!K4b$f2cd2>#8}#ed@IGs@wfue@0WtIa_P# z*o`fI0h*B?wS|aLMfEj+VJF3ka9H(6LIRS@srPPI*Y7Be*N%?|F`E^X@DE6B-vh|= zl8<%PwA1^}F8ZE?Z}~`Oh+ay;z8TYtn7r5eHDJ+&E(R#Ji^eGnKA9P$0t98K!J)q^ zXlscJk+gb*FwP~6h0vrCoB9>XeUsTdf{?95Ql*9P2(t*B1HW5ok& z)CLJS2O)eU@?8GPI%7#dknO7<7ULc^jjf2Ja0L_V!w|d|Mg^+>UG?&^p=j(Va^yS<)38#XQ(zHSh`a=4{6FdkVq+|{y8DQC8;k*EDQ zlC~^iet0qKtvl0nIZ)7c9(~oi{Y>G0vM=jw&1~~hwrh^Y!E9vFV4d0&r{U+Fp7eGnx`B>& zNFH*CS_KrrLtMFHY{f!l3MwpEg|Ve@GrNOxwqg;?(`C#D4RH;!KgD_`a0jC+u|HUl zZ`o+kXpYOSZZPCGAYT%*sw0-F-dIk)vtNacb(nVXCZeL$0=^j@L)&vm7u5p*mH2bHK&4UDU-Xg zwy7*=b9#3QIzRJZ?n z3Iht^KgtW<4{gTqO9EoEr8`*Cbb_y{+IxYf4mitQNbEHxGVi1$;jw(DSI3;Q%^5Hj zo?`D`KL75$HuFDQG!}_7ZDU!bLGAnD_dI4IA_yOavKC;H?sOQ*;(DizV`R%!_K=ag@BZm+C08kUn&lI4@l27 zK81#M_!BZEy0gknuhbhvA=tA9MoWKDft9W*UAgIbYlIi3kwFL76)qJ@bdI>BzFg=Q+ zqe7)+MvCyQ_c#a5=AvBw>*6O{6HSHaOx)GB3Dv` zf=ortIjb-5ZG`~L5CMwdW>r5)!OWus_nTi;#o!gQb@x8-Rd4!5FX79*2QZTflAPk1 zu0rwZ+lxW(`6jVOc}E)K z)d7NmHiuxcfx;z|v*ZF={Lp8}2mC|~oFX>I||1}tha zjQgZnmKB$k<6{)gjjpG#mPNU}RyMlqq1-pyJID1J+(UkuB+x_PkIHJHY?%vD;o!1D z4lxUo{Sum9{kURJ*As0Xexd;r>hAF3;uogr#bh=JrgHHYv)Dc%NpK@vgI*?yXnC#x zQqkXy%KS#EXAiOCUq~C$KH@q4BL5F%K$^dqo;B`jO`A-8wUO3iSFuI1QFpC>sV>^t zTpPb*8+VV{$?-N`wm(|?vz}WamgNlA zI%VKW#@>kCfntqKtV)X!Ect zpl%U%7lQKunlaMZIctGUJKs}PQtON~dy6=$GcmY(V6lC0UPV(iYthaunYnD{8HauI z@^DoCP4828zc{nR)uY{`tElNuR<`{znb@*paq9+Wu`^5ds_%HkbAIP=QX)62bW zZPcccmt=-LsAcJVJ|(&%v};GHcymBj{HIjCi46q)r59yJ&;DB(TwFMN3F_5PTc;GE%JqM;DlIe zlW==70|Aq=B`IWs_(pWtC37i-8E46XjBzwF#u*~ukR^)U$~Y{=AnDXi+sY8tP6y&y z#hNv<@^~%_M>}WDSFn$iPQYGf5zQmZ$f~bTLUVQj{A;z7zz3Yx^LKI&Yj!d=Cdr6J zaomrEj)j)CU}8gpJx-@nN=0T2y}+@SI4zv%m8pN~9MY6BHUqe4eWSQ%_A9LmYAyDk z>>T*Vmf5ZRlWRBj$JrUT%xnf7d|2kmi`*%oDV?iLBPnw{r&F`SR-8!&0fI7D+dw6j zW2rAoN5&8uBqupmO|gc8`-$<)J&l-0?Vlp^I8K|-OqdDNefee1s(BGOs0u4fa0U-H43eOLyf$H~ZqazAq(;^JpMLmNMe~;nVt7=8 z(=!|p*?suoUd3nAfCnFe5J-zdmF=TqM9A_HjRQyaW_5Ng7XExf*6VYiIk@~`mIl=2 zLbjqj3>E>76T&0-L41K9L#yx{uhHu472(MENM&AUcX%gUq1Hj;qm5Jy-B=ISUg?F! z!O5ruY3VOUls(`nRzoKzr*OWqm2|KE@saxLQgb&N_n2lHA2GVB2B+6m)ycmZPW9~g z@-M@Yog-V=qj!Grl>O6BG*kKm!J49hOTDZsUKx`A%_Kic)(X!)l3MR$clKPQx)IbN zhrL%p<`+tiRP-h#rxG_{+gZ+LD#JZ;HVwLx*k(~^r)YlvLmClzX*ZKskQ_#Arw}C0 z_zBMWX1zF_)FEUw4q6hhJ7`HD`ToC9MBy7g{6+?j6KpJ-&cOF+a+KvXJ_SW=!2le6 z^^=?)8}{b8u&Lby*3^#$Ron_^Q3jQ@VE&ly{{q1PIJ-F9rx0*Z5lK$D?>>?|t#2%@ z4mlRon(cqHGqBr#V*bflMZ4x*`nsrbYv;7GXO7jgE4nW&Wo=p`kK!Xod5y6veV@qw zifyL6eS*y4Zs6OQGA6_vW0VmxA)aZ;C%be>kY21wx1eZM#zoKTg?TzOvz%|m2;*2! z59l!$ahIVU3)r%(R&XjqC9^t%rHH2UE9|)aQg9aI2O(S5U{Su#+p`R-)hnHyDZ2F{ zpS+zlJ&uE9_DD!o}zhT(BuelfG3$++^fdnLUw znanZ7rL*8BABo|Sb8@@}FVKCL5)YTG37Y-a;S^p;Z=nSM+L(6k{kJGv(G9Wc_Si|= zVu_^J0R}ARLJa6&CORG-x3VQkfR4DueQi~Amb*?44w{3{Zoxt9qxPY3Z7?XFYD2J10$#2aP}V>mMyUg$_C zNrQeKc(z!SO4>pmLM`EoFOr{JO!J$#hc9jbUJ)+e!zTf+A!aG_0+X3a=gojc45FMR z)xA8+s9`n2?>8!m@fPD)c1DX&PvZ?bCNiFzLC>-RUY7cdV`7US&4cgap876jBFF5Zd#mYlajdGw9B4})wNNjpS0NQ z&oP{@D%Ll5NntEnqMYO>V&g^!ocylX)4#agfvEW~;w96oCn=yOI z@WSfe;n}^_*R3+nI`F_8UwTek&GfGN@<4T4dSqMOv_%W2)wOJ!{_eTh@VbEpvS;e% zt*h$JeI8!6x+;yA3xnkMM(hhB1SrC76SZ5|*M68RL=wFF@mJ+8PV$IajI< z52n?u{CsPxAh`<@=fUEkKcPCZSk00g5!@2S8H~K(^3^p^O%tkyYi6y^VzoQmKBm03 zPHh|SBK~qBSc$)$*af7&0958+VZai){J3*`@c<2m&%}BVkqRzc$>GFe(vG(4f?*WY#b1$%WY2F3>8^OshHZ0wj6J-`frU*}5 zaN@?xWvGtVI@KSNWB6o}7j8;*>kll2ixXiG~C^eCS4vPq^c*9CSDHvm@_F zNq+Vn+JQ}6BfE*wFbi;Aov2A-A1$^Ct3%K8W6rg7s5?iSwKE)jk_pEeV))b&iu>~) zOrf~bd>RLiT48cI_(0Jx`}U?k43eKb2C)U5-S=Og9nLcY%;S8Rf1VMTQl<)|3V6qZ z+@b+-GRJX1036+Q?eT8+0FEyL}&@tl`ut{)e$@y9(toU5u@u{wdzDX5<`{LQUw zV9iTheHCb8v&Q3$;7(J~Zxh35(X9lCQ73NYwHH^0@uO;+6kcAu3vw zLd={`5JQKWDV`H3Zur<4PJK2;*%jKzS@7a_4jm=oO3`NhB|MVKAOijk;KsvLve-Z=F{oze(S zMglxv4c)141%g`=^nnLD`WP4-b#2k>z!qDT5sB6~jz6+1&q0_pp5H2DwN@QCz!m{~ zG(Bzv!=o|@PKV?9wFYz1cq6iYAN#<8LQ0>wgh!V z8T$ge7BFf?%F~(V9*PmoF5-R-Jf~Kp4%wm+=&5pS%Y>&~WJ5-EzPU-6PmP-&>%rLq zL9Z9apRz=^Sqq@)pV2rxAMyXVcCwfgF+%B$V(x`uH4)|=LDpF8Jd_CW3Ml4frE z>?N(7r#kFuitL%$ztiJB+!twBqCnTPc4dG2)^P2y&Ci;HvkUFdb5v2Je1e?VocanDPlh-9yKZd1#`d}*k zc)z|{Wf6%WGWdWgF*rJ*@IbdTsoR@^LY51%7-R2q3qlFq}22KLO2}YSE zwCIm*?-f|WlI(N_fIgt~ClZ^4bvxPbUz!0JE|_|D@8D($u|hi(zLj zB|oj1kBQi5azgQnHgK<6lH=u%P*0t_yp*>BueupO^K)g_T*m5f=BH@T>Yej-9FLW8 zI_Oq8#()7f&C^&7xU-zjvBSYx`AXh5&i_Gy%PEMiQJh_4A1&+-!%ezgnj5#^aE+`` zL<tOt&5>*jvo08N9PJS9a`aIWfrep z8Au!aXxHgIdrp&cr?0*C^ll}-W#m3`4zz12)1%Du$EtX6%FVGW8-v&0iP#dRz zU476RwQ4RL-(<%S)z+dsD@YwH@79e+0ik%^Tu*6bRaHaVHG>^}vF@HPuNhb+@9Rx9 z2P$G75|5u0S{gRp;HCFe$NrGq4?Ln}mMhP)5m{JT676iDFe4ZTEaQGC$^q;C{G5{a zV(K*_!MvB|6Ak)T3|Lt#*Ab;`sXoh&Q=erQN+nWU-Km|{`yT#l_J?dHx$xqTao-tt zmffWJh^fOGI{y8fS;4`H`ZPv&5@eh)%AH<=qE+*~_{uYzaJQmoJQk@)(0v>p+R~$*y*M(_&%7~h=dAz?F+SnE_@D=j!u)AB zy{LTT*u1)h)X%8^KYGi!9}S+aE~yS@rVryQ_|eK#<|2~1VmR}YJ~-RuApg5KKc-nO zjB5r}dzXAh(?|EZ8JQ62QYJQwMs?S$A(=CIbU5XK6%0Aq@V9G~53k^EP4@6R})Wexbi_du| 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 X+{g_g+5ib^I86Wm00B}jeZ&9&#h}%} literal 0 HcmV?d00001 diff --git a/couchpotato/static/style/main.css b/couchpotato/static/style/main.css index e79dbea..3b81056 100644 --- a/couchpotato/static/style/main.css +++ b/couchpotato/static/style/main.css @@ -127,6 +127,7 @@ body > .spinner, .mask{ line-height: 1; border-radius: 2px; cursor: pointer; + border: none; } .button.red { background-color: #ff0000; } .button.green { background-color: #2aa300; } @@ -199,7 +200,7 @@ body > .spinner, .mask{ top: -3px; } .icon2.menu:before { - content: "\e076 \e076 \e076"; + content: "\e076 \e076 \e076"; line-height: 6px; transform: scaleX(2); width: 20px; @@ -257,13 +258,14 @@ body > .spinner, .mask{ .header .logo { display: inline-block; - font-size: 1.75em; - padding: 15px 30px 0 15px; + font-size: 3em; + padding: 4px 30px 0 15px; height: 100%; border-right: 1px solid rgba(255,255,255,.07); color: #FFF; font-weight: normal; vertical-align: top; + font-family: Lobster; } @media all and (max-width: 480px) { @@ -274,6 +276,7 @@ body > .spinner, .mask{ .header .logo { padding-top: 7px; border: 0; + font-size: 1.7em; } } @@ -788,6 +791,73 @@ body > .spinner, .mask{ right: 0; color: #FFF; } + +/*** Login ***/ +.page.login { + display: block; +} + + .login h1 { + padding: 0 0 10px; + font-size: 60px; + font-family: Lobster; + font-weight: normal; + } + + .login form { + padding: 0; + height: 300px; + width: 400px; + position: fixed; + left: 50%; + top: 50%; + margin: -200px 0 0 -200px; + } + @media all and (max-width: 480px) { + + .login form { + padding: 0; + height: 300px; + width: 90%; + position: absolute; + left: 5%; + top: 10px; + margin: 0; + } + + } + + .page.login .ctrlHolder { + padding: 0; + margin: 0 0 20px; + } + .page.login .ctrlHolder:hover { + background: none; + } + + .page.login input[type=text], + .page.login input[type=password] { + width: 100% !important; + font-size: 25px; + padding: 14px !important; + } + + .page.login .remember_me { + font-size: 15px; + float: left; + width: 150px; + padding: 20px 0; + } + + .page.login .remember_me .check { + margin: 5px 5px 0 0; + } + + .page.login .button { + font-size: 25px; + padding: 20px; + float: right; + } /* Fonts */ @font-face { @@ -846,5 +916,15 @@ body > .spinner, .mask{ url('../fonts/OpenSans-BoldItalic-webfont.svg#OpenSansBoldItalic') format('svg'); font-weight: bold; font-style: italic; +} +@font-face { + font-family: 'Lobster'; + src: url('../fonts/Lobster-webfont.eot'); + src: url('../fonts/Lobster-webfont.eot?#iefix') format('embedded-opentype'), + url('../fonts/Lobster-webfont.woff') format('woff'), + url('../fonts/Lobster-webfont.ttf') format('truetype'), + url('../fonts/Lobster-webfont.svg#lobster_1.4regular') format('svg'); + font-weight: normal; + font-style: normal; } \ No newline at end of file diff --git a/couchpotato/templates/login.html b/couchpotato/templates/login.html index d06440c..3562622 100644 --- a/couchpotato/templates/login.html +++ b/couchpotato/templates/login.html @@ -5,14 +5,34 @@ - Login + {% for url in fireEvent('clientscript.get_styles', as_html = True, location = 'front', single = True) %} + {% end %} + + {% for url in fireEvent('clientscript.get_scripts', as_html = True, location = 'front', single = True) %} + {% end %} + + + + + + + CouchPotato - +

-
-
-
-
+

CouchPotato

+
+
+
+ + +
\ No newline at end of file From 31b3c2ef64a95b6469d42c8abb39efc0f6bf77c5 Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 10 Sep 2013 22:59:31 +0200 Subject: [PATCH 47/55] Change static path --- couchpotato/core/_base/clientscript/main.py | 2 +- couchpotato/core/plugins/base.py | 2 +- couchpotato/runner.py | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/couchpotato/core/_base/clientscript/main.py b/couchpotato/core/_base/clientscript/main.py index efbaa64..b72105b 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' % (Env.setting('api_key'), rel_path) + core_url = 'static/%s' % rel_path if static_type == 'script': self.registerScript(core_url, file_path, position = 'front') diff --git a/couchpotato/core/plugins/base.py b/couchpotato/core/plugins/base.py index bd34270..b9ec0c0 100644 --- a/couchpotato/core/plugins/base.py +++ b/couchpotato/core/plugins/base.py @@ -83,7 +83,7 @@ class Plugin(object): class_name = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower() # View path - path = 'api/%s/static/%s/' % (Env.setting('api_key'), class_name) + path = 'static/plugin/%s/' % (class_name) # Add handler to Tornado Env.get('app').add_handlers(".*$", [(Env.get('web_base') + path + '(.*)', StaticFileHandler, {'path': static_folder})]) diff --git a/couchpotato/runner.py b/couchpotato/runner.py index 19ccf4e..c57e077 100644 --- a/couchpotato/runner.py +++ b/couchpotato/runner.py @@ -234,7 +234,7 @@ def runCouchPotato(options, base_path, args, data_dir = None, log_dir = None, En debug = config['use_reloader'], gzip = True, cookie_secret = api_key, - login_url = "/login", + login_url = '%slogin/' % web_base, ) Env.set('app', application) @@ -246,7 +246,7 @@ def runCouchPotato(options, base_path, args, data_dir = None, log_dir = None, En (r'%s(.*)(/?)' % api_base, ApiHandler), # Main API handler (r'%sgetkey(/?)' % web_base, KeyHandler), # Get API key (r'%s' % api_base, RedirectHandler, {"url": web_base + 'docs/'}), # API docs - + # Login handlers (r'%slogin(/?)' % web_base, LoginHandler), (r'%slogout(/?)' % web_base, LogoutHandler), @@ -257,7 +257,7 @@ def runCouchPotato(options, base_path, args, data_dir = None, log_dir = None, En ]) # Static paths - static_path = '%sstatic/' % api_base + static_path = '%sstatic/' % web_base for dir_name in ['fonts', 'images', 'scripts', 'style']: application.add_handlers(".*$", [ ('%s%s/(.*)' % (static_path, dir_name), StaticFileHandler, {'path': toUnicode(os.path.join(base_path, 'couchpotato', 'static', dir_name))}) From 0634c79f74a7f5343cc44f7f2b36dbfb46435ebb Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 10 Sep 2013 23:21:31 +0200 Subject: [PATCH 48/55] Give minified own FileHandler --- couchpotato/core/_base/clientscript/main.py | 14 +++++++++++--- couchpotato/core/plugins/file/main.py | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/couchpotato/core/_base/clientscript/main.py b/couchpotato/core/_base/clientscript/main.py index b72105b..1b7f163 100644 --- a/couchpotato/core/_base/clientscript/main.py +++ b/couchpotato/core/_base/clientscript/main.py @@ -6,6 +6,7 @@ from couchpotato.core.plugins.base import Plugin from couchpotato.environment import Env from minify.cssmin import cssmin from minify.jsmin import jsmin +from tornado.web import StaticFileHandler import os import re import traceback @@ -90,6 +91,13 @@ class ClientScript(Plugin): def minify(self): + # Create cache dir + cache = Env.get('cache_dir') + parent_dir = os.path.join(cache, 'minified') + self.makeDir(parent_dir) + + Env.get('app').add_handlers(".*$", [(Env.get('web_base') + 'minified/(.*)', StaticFileHandler, {'path': parent_dir})]) + for file_type in ['style', 'script']: ext = 'js' if file_type is 'script' else 'css' positions = self.paths.get(file_type, {}) @@ -100,8 +108,8 @@ class ClientScript(Plugin): def _minify(self, file_type, files, position, out): cache = Env.get('cache_dir') - out_name = 'minified_' + out - out = os.path.join(cache, out_name) + out_name = out + out = os.path.join(cache, 'minified', out_name) raw = [] for file_path in files: @@ -131,7 +139,7 @@ class ClientScript(Plugin): if not self.minified[file_type].get(position): self.minified[file_type][position] = [] - minified_url = 'api/%s/file.cache/%s?%s' % (Env.setting('api_key'), out_name, tryInt(os.path.getmtime(out))) + minified_url = 'minified/%s?%s' % (out_name, tryInt(os.path.getmtime(out))) self.minified[file_type][position].append(minified_url) def getStyles(self, *args, **kwargs): diff --git a/couchpotato/core/plugins/file/main.py b/couchpotato/core/plugins/file/main.py index 2f458f7..238bc76 100644 --- a/couchpotato/core/plugins/file/main.py +++ b/couchpotato/core/plugins/file/main.py @@ -71,7 +71,7 @@ class FileManager(Plugin): db = get_session() for root, dirs, walk_files in os.walk(Env.get('cache_dir')): for filename in walk_files: - if root == python_cache or 'minified' in filename or 'version' in filename or 'temp_updater' in root: continue + if root == python_cache or 'minified' in root or 'version' in filename or 'temp_updater' in root: continue file_path = os.path.join(root, filename) f = db.query(File).filter(File.path == toUnicode(file_path)).first() if not f: From 023278e0c07a928f5beef2967fe42d1071b42298 Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 10 Sep 2013 23:32:51 +0200 Subject: [PATCH 49/55] Remove webkit button styling --- couchpotato/static/style/main.css | 1 + 1 file changed, 1 insertion(+) diff --git a/couchpotato/static/style/main.css b/couchpotato/static/style/main.css index 3b81056..18af83f 100644 --- a/couchpotato/static/style/main.css +++ b/couchpotato/static/style/main.css @@ -128,6 +128,7 @@ body > .spinner, .mask{ border-radius: 2px; cursor: pointer; border: none; + -webkit-appearance: none; } .button.red { background-color: #ff0000; } .button.green { background-color: #2aa300; } From 43af25a30e7810d4633cc617fbf4c6faa22a01f3 Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 10 Sep 2013 23:50:17 +0200 Subject: [PATCH 50/55] Fix menu phone styling --- couchpotato/static/style/main.css | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/couchpotato/static/style/main.css b/couchpotato/static/style/main.css index 18af83f..0ade518 100644 --- a/couchpotato/static/style/main.css +++ b/couchpotato/static/style/main.css @@ -201,14 +201,22 @@ body > .spinner, .mask{ top: -3px; } .icon2.menu:before { - content: "\e076 \e076 \e076"; + content: "\e076\00a0 \e076\00a0 \e076\00a0"; line-height: 6px; transform: scaleX(2); width: 20px; font-size: 10px; display: inline-block; vertical-align: middle; + word-wrap: break-word; + text-align:center; + margin-left: 5px; } + @media screen and (-webkit-min-device-pixel-ratio:0) { + .icon2.menu:before { + margin-top: -7px; + } + } /*** Navigation ***/ .header { From 25693d44eb853cc384c9af041ce96252b7ef67f2 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 11 Sep 2013 09:07:32 +0200 Subject: [PATCH 51/55] Count NONE as success for NZBGet. fix #2135 --- couchpotato/core/downloaders/nzbget/main.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/couchpotato/core/downloaders/nzbget/main.py b/couchpotato/core/downloaders/nzbget/main.py index 35d47de..b7cf026 100644 --- a/couchpotato/core/downloaders/nzbget/main.py +++ b/couchpotato/core/downloaders/nzbget/main.py @@ -142,7 +142,7 @@ class NZBGet(Downloader): statuses.append({ 'id': nzb_id, 'name': item['NZBFilename'], - 'status': 'completed' if item['ParStatus'] == 'SUCCESS' and item['ScriptStatus'] == 'SUCCESS' else 'failed', + 'status': 'completed' if item['ParStatus'] in ['SUCCESS','NONE'] and item['ScriptStatus'] in ['SUCCESS','NONE'] else 'failed', 'original_status': item['ParStatus'] + ', ' + item['ScriptStatus'], 'timeleft': str(timedelta(seconds = 0)), 'folder': ss(item['DestDir']) @@ -178,9 +178,10 @@ class NZBGet(Downloader): 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'] + for param in hist['Parameters']: + if param['Name'] == 'couchpotato' and param['Value'] == item['id']: + nzb_id = hist['ID'] + path = hist['DestDir'] if nzb_id and path and rpc.editqueue('HistoryDelete', 0, "", [tryInt(nzb_id)]): shutil.rmtree(path, True) From b56cd3439e3f827dddb652bf900d7de50f5d168f Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 11 Sep 2013 09:28:30 +0200 Subject: [PATCH 52/55] added_identifiers needs to be mutable. fix #2140 #2141 --- couchpotato/core/plugins/manage/main.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/couchpotato/core/plugins/manage/main.py b/couchpotato/core/plugins/manage/main.py index 3a475b7..ec86f59 100644 --- a/couchpotato/core/plugins/manage/main.py +++ b/couchpotato/core/plugins/manage/main.py @@ -169,8 +169,7 @@ class Manage(Plugin): fireEvent('notify.frontend', type = 'manage.updating', data = False) self.in_progress = False - def createAddToLibrary(self, folder, added_identifiers = None): - if not added_identifiers: added_identifiers = [] + def createAddToLibrary(self, folder, added_identifiers = []): def addToLibrary(group, total_found, to_go): if self.in_progress[folder]['total'] is None: From c6403e87f180c5404a2b3980dd9c386257331496 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 11 Sep 2013 12:24:50 +0200 Subject: [PATCH 53/55] Get releases when cleaning up managed movies --- couchpotato/core/plugins/manage/main.py | 8 +++++--- couchpotato/core/plugins/release/main.py | 11 +++++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/couchpotato/core/plugins/manage/main.py b/couchpotato/core/plugins/manage/main.py index ec86f59..702b129 100644 --- a/couchpotato/core/plugins/manage/main.py +++ b/couchpotato/core/plugins/manage/main.py @@ -118,7 +118,9 @@ class Manage(Plugin): fireEvent('movie.delete', movie_id = done_movie['id'], delete_from = 'all') else: - for release in done_movie.get('releases', []): + releases = fireEvent('release.for_movie', id = done_movie.get('id'), single = True) + + for release in releases: if len(release.get('files', [])) == 0: fireEvent('release.delete', release['id']) else: @@ -129,9 +131,9 @@ class Manage(Plugin): break # Check if there are duplicate releases (different quality) use the last one, delete the rest - if len(done_movie.get('releases', [])) > 1: + if len(releases) > 1: used_files = {} - for release in done_movie.get('releases', []): + for release in releases: for release_file in release.get('files', []): already_used = used_files.get(release_file['path']) diff --git a/couchpotato/core/plugins/release/main.py b/couchpotato/core/plugins/release/main.py index 72089cc..46857ad 100644 --- a/couchpotato/core/plugins/release/main.py +++ b/couchpotato/core/plugins/release/main.py @@ -37,13 +37,14 @@ class Release(Plugin): 'id': {'type': 'id', 'desc': 'ID of the release object in release-table'} } }) - addApiView('release.for_movie', self.forMovie, docs = { + addApiView('release.for_movie', self.forMovieView, docs = { 'desc': 'Returns all releases for a movie. Ordered by score(desc)', 'params': { 'id': {'type': 'id', 'desc': 'ID of the movie'} } }) + addEvent('release.for_movie', self.forMovie) addEvent('release.delete', self.delete) addEvent('release.clean', self.clean) @@ -216,7 +217,7 @@ class Release(Plugin): 'success': False } - def forMovie(self, id = None, **kwargs): + def forMovie(self, id = None): db = get_session() @@ -229,6 +230,12 @@ class Release(Plugin): 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 + + def forMovieView(self, id = None, **kwargs): + + releases = self.forMovie(id) + return { 'releases': releases, 'success': True From a94307c59f9cdc325486f9785d76a341d84a8c0f Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 11 Sep 2013 21:33:11 +0200 Subject: [PATCH 54/55] rTorrent import cleanup --- couchpotato/core/downloaders/rtorrent/main.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/couchpotato/core/downloaders/rtorrent/main.py b/couchpotato/core/downloaders/rtorrent/main.py index 680c44a..161c671 100755 --- a/couchpotato/core/downloaders/rtorrent/main.py +++ b/couchpotato/core/downloaders/rtorrent/main.py @@ -1,20 +1,19 @@ 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.helpers.encoding import ss from couchpotato.core.logger import CPLog +from datetime import timedelta +from hashlib import sha1 from rtorrent import RTorrent - +from rtorrent.err import MethodError +import shutil log = CPLog(__name__) class rTorrent(Downloader): + protocol = ['torrent', 'torrent_magnet'] rt = None @@ -194,7 +193,7 @@ class rTorrent(Downloader): if torrent is None: return False - torrent.erase() # just removes the torrent, doesn't delete data + torrent.erase() # just removes the torrent, doesn't delete data if delete_files: shutil.rmtree(item['folder'], True) From 19c50f728e0398232d467ebb6649b909e41a3557 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 11 Sep 2013 22:41:38 +0200 Subject: [PATCH 55/55] Suggestions, mark as seen. --- couchpotato/core/plugins/suggestion/main.py | 27 ++++++++++++++++------ .../core/plugins/suggestion/static/suggest.css | 2 +- .../core/plugins/suggestion/static/suggest.js | 22 ++++++++++++++++-- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/couchpotato/core/plugins/suggestion/main.py b/couchpotato/core/plugins/suggestion/main.py index d6fdeb4..2cedeba 100644 --- a/couchpotato/core/plugins/suggestion/main.py +++ b/couchpotato/core/plugins/suggestion/main.py @@ -3,7 +3,7 @@ from couchpotato.api import addApiView from couchpotato.core.event import fireEvent from couchpotato.core.helpers.variable import splitString from couchpotato.core.plugins.base import Plugin -from couchpotato.core.settings.model import Movie +from couchpotato.core.settings.model import Movie, Library from couchpotato.environment import Env from sqlalchemy.orm import joinedload_all from sqlalchemy.sql.expression import or_ @@ -20,6 +20,7 @@ class Suggestion(Plugin): movies = splitString(kwargs.get('movies', '')) ignored = splitString(kwargs.get('ignored', '')) + seen = splitString(kwargs.get('seen', '')) cached_suggestion = self.getCache('suggestion_cached') if cached_suggestion: @@ -35,6 +36,8 @@ class Suggestion(Plugin): if not ignored or len(ignored) == 0: ignored = splitString(Env.prop('suggest_ignore', default = '')) + if not seen or len(seen) == 0: + movies.extend(splitString(Env.prop('suggest_seen', default = ''))) suggestions = fireEvent('movie.suggest', movies = movies, ignore = ignored, single = True) self.setCache('suggestion_cached', suggestions, timeout = 6048000) # Cache for 10 weeks @@ -45,17 +48,21 @@ class Suggestion(Plugin): 'suggestions': suggestions[:int(limit)] } - def ignoreView(self, imdb = None, limit = 6, remove_only = False, **kwargs): + def ignoreView(self, imdb = None, limit = 6, remove_only = False, mark_seen = False, **kwargs): ignored = splitString(Env.prop('suggest_ignore', default = '')) + seen = splitString(Env.prop('suggest_seen', default = '')) new_suggestions = [] if imdb: - if not remove_only: + if mark_seen: + seen.append(imdb) + Env.prop('suggest_seen', ','.join(set(seen))) + elif not remove_only: ignored.append(imdb) Env.prop('suggest_ignore', ','.join(set(ignored))) - new_suggestions = self.updateSuggestionCache(ignore_imdb = imdb, limit = limit, ignored = ignored) + new_suggestions = self.updateSuggestionCache(ignore_imdb = imdb, limit = limit, ignored = ignored, seen = seen) return { 'result': True, @@ -63,12 +70,13 @@ class Suggestion(Plugin): 'suggestions': new_suggestions[limit - 1:limit] } - def updateSuggestionCache(self, ignore_imdb = None, limit = 6, ignored = None): + def updateSuggestionCache(self, ignore_imdb = None, limit = 6, ignored = None, seen = None): # Combine with previous suggestion_cache cached_suggestion = self.getCache('suggestion_cached') new_suggestions = [] ignored = [] if not ignored else ignored + seen = [] if not seen else seen if ignore_imdb: for cs in cached_suggestion: @@ -78,10 +86,15 @@ class Suggestion(Plugin): # Get new results and add them if len(new_suggestions) - 1 < limit: + active_status, done_status = fireEvent('status.get', ['active', 'done'], single = True) + 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] + .join(Library) \ + .with_entities(Library.identifier) \ + .filter(Movie.status_id.in_([active_status.get('id'), done_status.get('id')])).all() + movies = [x[0] for x in active_movies] + movies.extend(seen) ignored.extend([x.get('imdb') for x in cached_suggestion]) suggestions = fireEvent('movie.suggest', movies = movies, ignore = list(set(ignored)), single = True) diff --git a/couchpotato/core/plugins/suggestion/static/suggest.css b/couchpotato/core/plugins/suggestion/static/suggest.css index 2b05abf..c321ca2 100644 --- a/couchpotato/core/plugins/suggestion/static/suggest.css +++ b/couchpotato/core/plugins/suggestion/static/suggest.css @@ -105,7 +105,7 @@ bottom: 10px; right: 10px; display: none; - width: 120px; + width: 140px; } .suggestions .movie_result:hover .actions { display: block; diff --git a/couchpotato/core/plugins/suggestion/static/suggest.js b/couchpotato/core/plugins/suggestion/static/suggest.js index 817d965..e622671 100644 --- a/couchpotato/core/plugins/suggestion/static/suggest.js +++ b/couchpotato/core/plugins/suggestion/static/suggest.js @@ -26,6 +26,20 @@ var SuggestList = new Class({ 'onComplete': self.fill.bind(self) }); + }, + 'click:relay(a.eye-open)': function(e, el){ + (e).stop(); + + $(el).getParent('.movie_result').destroy(); + + Api.request('suggestion.ignore', { + 'data': { + 'imdb': el.get('data-seen'), + 'mark_seen': 1 + }, + 'onComplete': self.fill.bind(self) + }); + } } }).grab( @@ -43,7 +57,7 @@ var SuggestList = new Class({ fill: function(json){ var self = this; - + if(!json) return; Object.each(json.suggestions, function(movie){ @@ -69,6 +83,10 @@ var SuggestList = new Class({ new Element('a.delete.icon2', { 'title': 'Don\'t suggest this movie again', 'data-ignore': movie.imdb + }), + new Element('a.eye-open.icon2', { + 'title': 'Seen it, like it, don\'t add', + 'data-seen': movie.imdb }) ) ); @@ -88,7 +106,7 @@ var SuggestList = new Class({ $(m).inject(self.el); }); - + self.fireEvent('loaded'); },