diff --git a/couchpotato/core/loader.py b/couchpotato/core/loader.py index 9d04632b..d1cf06e2 100644 --- a/couchpotato/core/loader.py +++ b/couchpotato/core/loader.py @@ -10,9 +10,19 @@ class Loader(object): plugins = {} providers = {} - modules = {} + def addPath(self, root, base_path, priority, recursive=False): + for filename in os.listdir(os.path.join(root, *base_path)): + path = os.path.join(os.path.join(root, *base_path), filename) + if os.path.isdir(path) and filename[:2] != '__': + if not u'__init__.py' in os.listdir(path): + return + new_base_path = ''.join(s + '.' for s in base_path) + filename + self.paths[new_base_path.replace('.', '_')] = (priority, new_base_path, path) + if recursive: + self.addPath(root, base_path + [filename], priority, recursive=True) + def preload(self, root = ''): core = os.path.join(root, 'couchpotato', 'core') @@ -25,19 +35,14 @@ class Loader(object): } # Add providers to loader - provider_dir = os.path.join(root, 'couchpotato', 'core', 'providers') - for provider in os.listdir(provider_dir): - path = os.path.join(provider_dir, provider) - if os.path.isdir(path) and provider[:2] != '__': - self.paths[provider + '_provider'] = (25, 'couchpotato.core.providers.' + provider, path) - + self.addPath(root, ['couchpotato', 'core', 'providers'], 25, recursive=False) + # Add media to loader - media_dir = os.path.join(root, 'couchpotato', 'core', 'media') - for media in os.listdir(media_dir): - path = os.path.join(media_dir, media) - if os.path.isdir(path) and media[:2] != '__': - self.paths[media + '_media'] = (25, 'couchpotato.core.media.' + media, path) - + self.addPath(root, ['couchpotato', 'core', 'media'], 25, recursive=False) + + # Add Libraries to loader + self.addPath(root, ['couchpotato', 'core', 'media', 'movie'], 1, recursive=False) + self.addPath(root, ['couchpotato', 'core', 'media', 'show'], 1, recursive=False) for plugin_type, plugin_tuple in self.paths.iteritems(): priority, module, dir_name = plugin_tuple diff --git a/couchpotato/core/media/_base/library/__init__.py b/couchpotato/core/media/_base/library/__init__.py new file mode 100644 index 00000000..588a42d7 --- /dev/null +++ b/couchpotato/core/media/_base/library/__init__.py @@ -0,0 +1,14 @@ +from couchpotato.core.event import addEvent +from couchpotato.core.logger import CPLog +from couchpotato.core.plugins.base import Plugin + + +class LibraryBase(Plugin): + + _type = None + + def initType(self): + addEvent('library.types', self.getType) + + def getType(self): + return self._type diff --git a/couchpotato/core/media/movie/_base/main.py b/couchpotato/core/media/movie/_base/main.py index 448e9985..8a88440c 100644 --- a/couchpotato/core/media/movie/_base/main.py +++ b/couchpotato/core/media/movie/_base/main.py @@ -319,7 +319,7 @@ class MovieBase(MovieTypeBase): if title.default: default_title = title.title fireEvent('notify.frontend', type = 'movie.busy.%s' % x, data = True) - fireEventAsync('library.update', identifier = movie.library.identifier, default_title = default_title, force = True, on_complete = self.createOnComplete(x)) + fireEventAsync('library.update.movie', identifier = movie.library.identifier, default_title = default_title, force = True, on_complete = self.createOnComplete(x)) db.expire_all() return { @@ -346,7 +346,6 @@ class MovieBase(MovieTypeBase): } def add(self, params = {}, force_readd = True, search_after = True, update_library = False, status_id = None): - if not params.get('identifier'): msg = 'Can\'t add movie without imdb identifier.' log.error(msg) @@ -364,7 +363,7 @@ class MovieBase(MovieTypeBase): pass - library = fireEvent('library.add', single = True, attrs = params, update_after = update_library) + library = fireEvent('library.add.movie', single = True, attrs = params, update_after = update_library) # Status status_active, snatched_status, ignored_status, done_status, downloaded_status = \ @@ -391,7 +390,7 @@ class MovieBase(MovieTypeBase): if search_after: onComplete = self.createOnComplete(m.id) - fireEventAsync('library.update', params.get('identifier'), default_title = params.get('title', ''), on_complete = onComplete) + fireEventAsync('library.update.movie', params.get('identifier'), default_title = params.get('title', ''), on_complete = onComplete) search_after = False elif force_readd: diff --git a/couchpotato/core/media/movie/_base/static/search.js b/couchpotato/core/media/movie/_base/static/search.js index 376e61c9..e7fff409 100644 --- a/couchpotato/core/media/movie/_base/static/search.js +++ b/couchpotato/core/media/movie/_base/static/search.js @@ -160,7 +160,7 @@ Block.Search = new Class({ self.movies[movie.imdb || 'r-'+Math.floor(Math.random()*10000)] = m if(q == movie.imdb) - m.showOptions() + m.movieOptions() }); @@ -212,7 +212,7 @@ Block.Search.Item = new Class({ self.options_el = new Element('div.options.inlay'), self.data_container = new Element('div.data', { 'events': { - 'click': self.showOptions.bind(self) + 'click': self.movieOptions.bind(self) } }).adopt( self.info_container = new Element('div.info').adopt( @@ -256,7 +256,7 @@ Block.Search.Item = new Class({ return this.info[key] }, - showOptions: function(){ + movieOptions: function(){ var self = this; self.createOptions(); @@ -366,7 +366,7 @@ Block.Search.Item = new Class({ if(categories.length == 0) self.category_select.hide(); else { - self.category_select.show(); + self.category_select.movie(); categories.each(function(category){ new Element('option', { 'value': category.data.id, diff --git a/couchpotato/core/media/tv/__init__.py b/couchpotato/core/media/movie/library/__init__.py similarity index 100% rename from couchpotato/core/media/tv/__init__.py rename to couchpotato/core/media/movie/library/__init__.py diff --git a/couchpotato/core/media/movie/library/movie/__init__.py b/couchpotato/core/media/movie/library/movie/__init__.py new file mode 100644 index 00000000..03494a11 --- /dev/null +++ b/couchpotato/core/media/movie/library/movie/__init__.py @@ -0,0 +1,6 @@ +from .main import MovieLibraryPlugin + +def start(): + return MovieLibraryPlugin() + +config = [] diff --git a/couchpotato/core/media/movie/library/movie/main.py b/couchpotato/core/media/movie/library/movie/main.py new file mode 100644 index 00000000..51091273 --- /dev/null +++ b/couchpotato/core/media/movie/library/movie/main.py @@ -0,0 +1,177 @@ +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 string import ascii_letters +import time +import traceback + +log = CPLog(__name__) + + +class MovieLibraryPlugin(LibraryBase): + + default_dict = {'titles': {}, 'files':{}} + + def __init__(self): + addEvent('library.add.movie', self.add) + addEvent('library.update.movie', self.update) + addEvent('library.update.movie_release_date', self.updateReleaseDate) + + def add(self, attrs = {}, update_after = True): + # movies don't yet contain these, so lets make sure to set defaults + type = attrs.get('type', 'movie') + primary_provider = attrs.get('primary_provider', 'imdb') + + db = get_session() + + l = db.query(Library).filter_by(type = type, identifier = attrs.get('identifier')).first() + if not l: + status = fireEvent('status.get', 'needs_update', single = True) + l = Library( + type = type, + primary_provider = primary_provider, + year = attrs.get('year'), + identifier = attrs.get('identifier'), + plot = toUnicode(attrs.get('plot')), + tagline = toUnicode(attrs.get('tagline')), + status_id = status.get('id'), + info = {}, + parent = None, + ) + + title = LibraryTitle( + title = toUnicode(attrs.get('title')), + simple_title = self.simplifyTitle(attrs.get('title')), + ) + + l.titles.append(title) + + db.add(l) + db.commit() + + # Update library info + if update_after is not False: + handle = fireEventAsync if update_after is 'async' else fireEvent + handle('library.update.movie', identifier = l.identifier, default_title = toUnicode(attrs.get('title', ''))) + + library_dict = l.to_dict(self.default_dict) + + db.expire_all() + return library_dict + + def update(self, identifier, default_title = '', force = False): + + if self.shuttingDown(): + return + + db = get_session() + library = db.query(Library).filter_by(identifier = identifier).first() + done_status = fireEvent('status.get', 'done', single = True) + + if library: + library_dict = library.to_dict(self.default_dict) + + do_update = True + + info = fireEvent('movie.info', merge = True, identifier = identifier) + + # Don't need those here + try: del info['in_wanted'] + except: pass + try: del info['in_library'] + except: pass + + if not info or len(info) == 0: + log.error('Could not update, no movie info to work with: %s', identifier) + return False + + # Main info + if do_update: + library.plot = toUnicode(info.get('plot', '')) + library.tagline = toUnicode(info.get('tagline', '')) + library.year = info.get('year', 0) + library.status_id = done_status.get('id') + library.info.update(info) + db.commit() + + # Titles + [db.delete(title) for title in library.titles] + db.commit() + + titles = info.get('titles', []) + log.debug('Adding titles: %s', titles) + counter = 0 + for title in titles: + if not title: + continue + title = toUnicode(title) + t = LibraryTitle( + title = title, + simple_title = self.simplifyTitle(title), + default = (len(default_title) == 0 and counter == 0) or len(titles) == 1 or title.lower() == toUnicode(default_title.lower()) or (toUnicode(default_title) == u'' and toUnicode(titles[0]) == title) + ) + library.titles.append(t) + counter += 1 + + db.commit() + + # Files + images = info.get('images', []) + for image_type in ['poster']: + for image in images.get(image_type, []): + if not isinstance(image, (str, unicode)): + continue + + file_path = fireEvent('file.download', url = image, single = True) + if file_path: + file_obj = fireEvent('file.add', path = file_path, type_tuple = ('image', image_type), single = True) + try: + file_obj = db.query(File).filter_by(id = file_obj.get('id')).one() + library.files.append(file_obj) + db.commit() + + break + except: + log.debug('Failed to attach to library: %s', traceback.format_exc()) + + library_dict = library.to_dict(self.default_dict) + + db.expire_all() + return library_dict + + def updateReleaseDate(self, identifier): + + db = get_session() + library = db.query(Library).filter_by(identifier = identifier).first() + + if not library.info: + library_dict = self.update(identifier, force = True) + dates = library_dict.get('info', {}).get('release_date') + else: + dates = library.info.get('release_date') + + if dates and dates.get('expires', 0) < time.time() or not dates: + dates = fireEvent('movie.release_date', identifier = identifier, merge = True) + library.info.update({'release_date': dates }) + db.commit() + + db.expire_all() + return dates + + + def simplifyTitle(self, title): + + title = toUnicode(title) + + nr_prefix = '' if title[0] in ascii_letters else '#' + title = simplifyString(title) + + for prefix in ['the ']: + if prefix == title[:len(prefix)]: + title = title[len(prefix):] + break + + return nr_prefix + title diff --git a/couchpotato/core/media/movie/searcher/main.py b/couchpotato/core/media/movie/searcher/main.py index 8fb4acf1..7ea09d86 100644 --- a/couchpotato/core/media/movie/searcher/main.py +++ b/couchpotato/core/media/movie/searcher/main.py @@ -100,7 +100,7 @@ class MovieSearcher(SearcherBase, MovieTypeBase): self.single(movie_dict, search_protocols) except IndexError: log.error('Forcing library update for %s, if you see this often, please report: %s', (movie_dict['library']['identifier'], traceback.format_exc())) - fireEvent('library.update', movie_dict['library']['identifier'], force = True) + fireEvent('library.update.movie', movie_dict['library']['identifier'], force = True) except: log.error('Search failed for %s: %s', (movie_dict['library']['identifier'], traceback.format_exc())) @@ -133,7 +133,7 @@ class MovieSearcher(SearcherBase, MovieTypeBase): db = get_session() pre_releases = fireEvent('quality.pre_releases', single = True) - release_dates = fireEvent('library.update_release_date', identifier = movie['library']['identifier'], merge = True) + release_dates = fireEvent('library.update.movie_release_date', identifier = movie['library']['identifier'], merge = True) available_status, ignored_status, failed_status = fireEvent('status.get', ['available', 'ignored', 'failed'], single = True) found_releases = [] diff --git a/couchpotato/core/media/show/__init__.py b/couchpotato/core/media/show/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/couchpotato/core/media/show/_base/__init__.py b/couchpotato/core/media/show/_base/__init__.py new file mode 100644 index 00000000..5d185b45 --- /dev/null +++ b/couchpotato/core/media/show/_base/__init__.py @@ -0,0 +1,6 @@ +from .main import ShowBase + +def start(): + return ShowBase() + +config = [] diff --git a/couchpotato/core/media/show/_base/main.py b/couchpotato/core/media/show/_base/main.py new file mode 100644 index 00000000..f9fb52a9 --- /dev/null +++ b/couchpotato/core/media/show/_base/main.py @@ -0,0 +1,254 @@ +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.logger import CPLog +from couchpotato.core.media import MediaBase +from couchpotato.core.settings.model import Library, LibraryTitle, Movie, \ + Release +from couchpotato.environment import Env +from sqlalchemy.orm import joinedload_all +from sqlalchemy.sql.expression import or_, asc, not_, desc +from string import ascii_lowercase +import time + +log = CPLog(__name__) + + +class ShowBase(MediaBase): + + identifier = 'show' + + default_dict = { + 'profile': {'types': {'quality': {}}}, + 'releases': {'status': {}, 'quality': {}, 'files':{}, 'info': {}}, + 'library': {'titles': {}, 'files':{}}, + 'files': {}, + 'status': {} + } + + def __init__(self): + super(ShowBase, self).__init__() + + addApiView('show.search', self.search, docs = { + 'desc': 'Search the show providers for a show', + 'params': { + 'q': {'desc': 'The (partial) show name you want to search for'}, + }, + 'return': {'type': 'object', 'example': """{ + 'success': True, + 'empty': bool, any shows returned or not, + 'shows': array, shows found, +}"""} + }) + addApiView('show.add', self.addView, docs = { + 'desc': 'Add new movie to the wanted list', + 'params': { + 'identifier': {'desc': 'IMDB id of the movie your want to add.'}, + 'profile_id': {'desc': 'ID of quality profile you want the add the movie in. If empty will use the default profile.'}, + 'title': {'desc': 'Movie title to use for searches. Has to be one of the titles returned by movie.search.'}, + } + }) + + addEvent('show.add', self.add) + + def search(self, q = '', **kwargs): + + cache_key = u'%s/%s' % (__name__, simplifyString(q)) + shows = Env.get('cache').get(cache_key) + + if not shows: + + if getImdb(q): + shows = [fireEvent('show.info', identifier = q, merge = True)] + else: + shows = fireEvent('show.search', q = q, merge = True) + Env.get('cache').set(cache_key, shows) + + return { + 'success': True, + 'empty': len(shows) == 0 if shows else 0, + 'shows': shows, + } + + def addView(self, **kwargs): + + movie_dict = fireEvent('show.add', params=kwargs) # XXX: Temp added so we can catch a breakpoint + #movie_dict = self.add(params = kwargs) + + return { + 'success': True, + 'added': True if movie_dict else False, + 'movie': movie_dict, + } + + def debug(self): + """ + XXX: This is only a hook for a breakpoint so we can test database stuff easily + REMOVE when finished + """ + from couchpotato import get_session + from couchpotato.core.event import addEvent, fireEventAsync, fireEvent + from couchpotato.core.helpers.encoding import toUnicode, simplifyString + from couchpotato.core.logger import CPLog + from couchpotato.core.plugins.base import Plugin + from couchpotato.core.settings.model import Library, LibraryTitle, File + from string import ascii_letters + import time + import traceback + + db = get_session() + #parent = db.query(Library).filter_by(identifier = attrs.get('')).first() + return + + def add(self, params = {}, force_readd = True, search_after = True, update_library = False, status_id = None): + """ + 1. Add Show + 2. Add All Episodes + 3. Add All Seasons + + Notes, not to forget: + - relate parent and children, possible grandparent to grandchild so episodes know it belong to show, etc + - looks like we dont send info to library; it comes later + - change references to plot to description + - change Model to Media + + params + {'category_id': u'-1', + 'identifier': u'tt1519931', + 'profile_id': u'12', + 'thetvdb_id': u'158661', + 'title': u'Haven'} + """ + log.debug("show.add") + + # Add show parent to db first + parent = self.addToDatabase(params = params, type = 'show') + + skip = False # XXX: For debugging + identifier = params.get('id') + episodes = fireEvent('show.episodes', identifier = identifier) + + # XXX: Fix so we dont have a nested list + if episodes is not None and skip is False: + for episode in episodes[0]: + episode['title'] = episode.get('titles', None)[0] + episode['identifier'] = episode.get('id', None) + episode['parent_identifier'] = identifier + self.addToDatabase(params=episode, type = "episode") + + return parent + + def addToDatabase(self, params = {}, type="show", force_readd = True, search_after = True, update_library = False, status_id = None): + log.debug("show.addToDatabase") + + if not params.get('identifier'): + msg = 'Can\'t add show without imdb identifier.' + log.error(msg) + fireEvent('notify.frontend', type = 'show.is_tvshow', message = msg) + return False + #else: + #try: + #is_show = fireEvent('movie.is_show', identifier = params.get('identifier'), single = True) + #if not is_show: + #msg = 'Can\'t add show, seems to be a TV show.' + #log.error(msg) + #fireEvent('notify.frontend', type = 'show.is_tvshow', message = msg) + #return False + #except: + #pass + + library = fireEvent('library.add.%s' % type, single = True, attrs = params, update_after = update_library) + if not library: + return False + + # Status + status_active, snatched_status, ignored_status, done_status, downloaded_status = \ + fireEvent('status.get', ['active', 'snatched', 'ignored', 'done', 'downloaded'], single = True) + + default_profile = fireEvent('profile.default', single = True) + cat_id = params.get('category_id', None) + + db = get_session() + m = db.query(Movie).filter_by(library_id = library.get('id')).first() + added = True + do_search = False + if not m: + m = Movie( + type = type, + library_id = library.get('id'), + profile_id = params.get('profile_id', default_profile.get('id')), + status_id = status_id if status_id else status_active.get('id'), + category_id = tryInt(cat_id) if cat_id is not None and tryInt(cat_id) > 0 else None, + ) + db.add(m) + db.commit() + + onComplete = None + if search_after: + onComplete = self.createOnComplete(m.id) + + fireEventAsync('library.update.%s' % type, params.get('identifier'), default_title = params.get('title', ''), on_complete = onComplete) + search_after = False + elif force_readd: + + # Clean snatched history + for release in m.releases: + if release.status_id in [downloaded_status.get('id'), snatched_status.get('id'), done_status.get('id')]: + if params.get('ignore_previous', False): + release.status_id = ignored_status.get('id') + else: + fireEvent('release.delete', release.id, single = True) + + m.profile_id = params.get('profile_id', default_profile.get('id')) + m.category_id = tryInt(cat_id) if cat_id is not None and tryInt(cat_id) > 0 else None + else: + log.debug('Show already exists, not updating: %s', params) + added = False + + if force_readd: + m.status_id = status_id if status_id else status_active.get('id') + m.last_edit = int(time.time()) + do_search = True + + db.commit() + + # Remove releases + available_status = fireEvent('status.get', 'available', single = True) + for rel in m.releases: + if rel.status_id is available_status.get('id'): + db.delete(rel) + db.commit() + + show_dict = m.to_dict(self.default_dict) + + if do_search and search_after: + onComplete = self.createOnComplete(m.id) + onComplete() + + if added: + fireEvent('notify.frontend', type = 'show.added', data = show_dict, message = 'Successfully added "%s" to your wanted list.' % params.get('title', '')) + + db.expire_all() + return show_dict + + def createOnComplete(self, show_id): + + def onComplete(): + db = get_session() + show = db.query(Movie).filter_by(id = show_id).first() + fireEventAsync('show.searcher.single', show.to_dict(self.default_dict), on_complete = self.createNotifyFront(show_id)) + db.expire_all() + + return onComplete + + def createNotifyFront(self, show_id): + + def notifyFront(): + db = get_session() + show = db.query(Movie).filter_by(id = show_id).first() + fireEvent('notify.frontend', type = 'show.update.%s' % show.id, data = show.to_dict(self.default_dict)) + db.expire_all() + + return notifyFront diff --git a/couchpotato/core/media/show/_base/static/search.css b/couchpotato/core/media/show/_base/static/search.css new file mode 100644 index 00000000..ab648063 --- /dev/null +++ b/couchpotato/core/media/show/_base/static/search.css @@ -0,0 +1,275 @@ +.show_search_form { + display: inline-block; + vertical-align: middle; + position: absolute; + right: 135px; + top: 0; + text-align: left; + height: 100%; + border-bottom: 4px solid transparent; + transition: all .4s cubic-bezier(0.9,0,0.1,1); + position: absolute; + z-index: 20; + border: 1px solid transparent; + border-width: 0 0 4px; +} + .show_search_form:hover { + border-color: #047792; + } + + @media all and (max-width: 480px) { + .show_search_form { + right: 44px; + } + } + + .show_search_form.focused, + .show_search_form.shown { + border-color: #04bce6; + } + + .show_search_form .input { + height: 100%; + overflow: hidden; + width: 45px; + transition: all .4s cubic-bezier(0.9,0,0.1,1); + } + + .show_search_form.focused .input, + .show_search_form.shown .input { + width: 380px; + background: #4e5969; + } + + .show_search_form .input input { + border-radius: 0; + display: block; + border: 0; + background: none; + color: #FFF; + font-size: 25px; + height: 100%; + padding: 10px; + width: 100%; + opacity: 0; + padding: 0 40px 0 10px; + transition: all .4s ease-in-out .2s; + } + .show_search_form.focused .input input, + .show_search_form.shown .input input { + opacity: 1; + } + + @media all and (max-width: 480px) { + .show_search_form .input input { + font-size: 15px; + } + + .show_search_form.focused .input, + .show_search_form.shown .input { + width: 277px; + } + } + + .show_search_form .input a { + position: absolute; + top: 0; + right: 0; + width: 44px; + height: 100%; + cursor: pointer; + vertical-align: middle; + text-align: center; + line-height: 66px; + font-size: 15px; + color: #FFF; + } + + .show_search_form .input a:after { + content: "\e03e"; + } + + .show_search_form.shown.filled .input a:after { + content: "\e04e"; + } + + @media all and (max-width: 480px) { + .show_search_form .input a { + line-height: 44px; + } + } + + .show_search_form .results_container { + text-align: left; + position: absolute; + background: #5c697b; + margin: 4px 0 0; + width: 470px; + min-height: 50px; + box-shadow: 0 20px 20px -10px rgba(0,0,0,0.55); + display: none; + } + @media all and (max-width: 480px) { + .show_search_form .results_container { + width: 320px; + } + } + .show_search_form.focused.filled .results_container, + .show_search_form.shown.filled .results_container { + display: block; + } + + .show_search_form .results { + max-height: 570px; + overflow-x: hidden; + } + + .show_result { + overflow: hidden; + height: 50px; + position: relative; + } + + .show_result .options { + position: absolute; + height: 100%; + top: 0; + left: 30px; + right: 0; + padding: 13px; + border: 1px solid transparent; + border-width: 1px 0; + border-radius: 0; + box-shadow: inset 0 1px 8px rgba(0,0,0,0.25); + } + .show_result .options > .in_library_wanted { + margin-top: -7px; + } + + .show_result .options > div { + border: 0; + } + + .show_result .options .thumbnail { + vertical-align: middle; + } + + .show_result .options select { + vertical-align: middle; + display: inline-block; + margin-right: 10px; + } + .show_result .options select[name=title] { width: 170px; } + .show_result .options select[name=profile] { width: 90px; } + .show_result .options select[name=category] { width: 80px; } + + @media all and (max-width: 480px) { + + .show_result .options select[name=title] { width: 90px; } + .show_result .options select[name=profile] { width: 50px; } + .show_result .options select[name=category] { width: 50px; } + + } + + .show_result .options .button { + vertical-align: middle; + display: inline-block; + } + + .show_result .options .message { + height: 100%; + font-size: 20px; + color: #fff; + line-height: 20px; + } + + .show_result .data { + position: absolute; + height: 100%; + top: 0; + left: 30px; + right: 0; + background: #5c697b; + cursor: pointer; + border-top: 1px solid rgba(255,255,255, 0.08); + transition: all .4s cubic-bezier(0.9,0,0.1,1); + } + .show_result .data.open { + left: 100% !important; + } + + .show_result:last-child .data { border-bottom: 0; } + + .show_result .in_wanted, .show_result .in_library { + position: absolute; + bottom: 2px; + left: 14px; + font-size: 11px; + } + + .show_result .thumbnail { + width: 34px; + min-height: 100%; + display: block; + margin: 0; + vertical-align: top; + } + + .show_result .info { + position: absolute; + top: 20%; + left: 15px; + right: 7px; + vertical-align: middle; + } + + .show_result .info h2 { + margin: 0; + font-weight: normal; + font-size: 20px; + padding: 0; + } + + .show_search_form .info h2 { + position: absolute; + width: 100%; + } + + .show_result .info h2 .title { + display: block; + margin: 0; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + .show_search_form .info h2 .title { + position: absolute; + width: 88%; + } + + .show_result .info h2 .year { + padding: 0 5px; + text-align: center; + position: absolute; + width: 12%; + right: 0; + } + + @media all and (max-width: 480px) { + + .show_search_form .info h2 .year { + font-size: 12px; + margin-top: 7px; + } + + } + +.show_search_form .mask, +.show_result .mask { + position: absolute; + height: 100%; + width: 100%; + left: 0; + top: 0; +} \ No newline at end of file diff --git a/couchpotato/core/media/show/_base/static/search.js b/couchpotato/core/media/show/_base/static/search.js new file mode 100644 index 00000000..6a089f89 --- /dev/null +++ b/couchpotato/core/media/show/_base/static/search.js @@ -0,0 +1,417 @@ +Block.ShowSearch = new Class({ + + Extends: BlockBase, + + cache: {}, + + create: function(){ + var self = this; + + var focus_timer = 0; + self.el = new Element('div.show_search_form').adopt( + new Element('div.input').adopt( + self.input = new Element('input', { + 'placeholder': 'Search & add a new *show*', + 'events': { + 'keyup': self.keyup.bind(self), + 'focus': function(){ + if(focus_timer) clearTimeout(focus_timer); + self.el.addClass('focused') + if(this.get('value')) + self.hideResults(false) + }, + 'blur': function(){ + focus_timer = (function(){ + self.el.removeClass('focused') + }).delay(100); + } + } + }), + new Element('a.icon2', { + 'events': { + 'click': self.clear.bind(self), + 'touchend': self.clear.bind(self) + } + }) + ), + self.result_container = new Element('div.results_container', { + 'tween': { + 'duration': 200 + }, + 'events': { + 'mousewheel': function(e){ + (e).stopPropagation(); + } + } + }).adopt( + self.results = new Element('div.results') + ) + ); + + self.mask = new Element('div.mask').inject(self.result_container).fade('hide'); + + }, + + clear: function(e){ + var self = this; + (e).preventDefault(); + + if(self.last_q === ''){ + self.input.blur() + self.last_q = null; + } + else { + + self.last_q = ''; + self.input.set('value', ''); + self.input.focus() + + self.shows = [] + self.results.empty() + self.el.removeClass('filled') + + } + }, + + hideResults: function(bool){ + var self = this; + + if(self.hidden == bool) return; + + self.el[bool ? 'removeClass' : 'addClass']('shown'); + + if(bool){ + History.removeEvent('change', self.hideResults.bind(self, !bool)); + self.el.removeEvent('outerClick', self.hideResults.bind(self, !bool)); + } + else { + History.addEvent('change', self.hideResults.bind(self, !bool)); + self.el.addEvent('outerClick', self.hideResults.bind(self, !bool)); + } + + self.hidden = bool; + }, + + keyup: function(e){ + var self = this; + + self.el[self.q() ? 'addClass' : 'removeClass']('filled') + + if(self.q() != self.last_q){ + if(self.api_request && self.api_request.isRunning()) + self.api_request.cancel(); + + if(self.autocomplete_timer) clearTimeout(self.autocomplete_timer) + self.autocomplete_timer = self.autocomplete.delay(300, self) + } + + }, + + autocomplete: function(){ + var self = this; + + if(!self.q()){ + self.hideResults(true) + return + } + + self.list() + }, + + list: function(){ + var self = this, + q = self.q(), + cache = self.cache[q]; + + self.hideResults(false); + + if(!cache){ + self.mask.fade('in'); + + if(!self.spinner) + self.spinner = createSpinner(self.mask); + + self.api_request = Api.request('show.search', { + 'data': { + 'q': q + }, + 'onComplete': self.fill.bind(self, q) + }) + } + else + self.fill(q, cache) + + self.last_q = q; + + }, + + fill: function(q, json){ + var self = this; + + self.cache[q] = json + + self.shows = {} + self.results.empty() + + Object.each(json.shows, function(show){ + + var m = new Block.ShowSearch.Item(show); + $(m).inject(self.results) + self.shows[show.imdb || 'r-'+Math.floor(Math.random()*10000)] = m + + if(q == show.imdb) + m.showOptions() + + }); + + // Calculate result heights + var w = window.getSize(), + rc = self.result_container.getCoordinates(); + + self.results.setStyle('max-height', (w.y - rc.top - 50) + 'px') + self.mask.fade('out') + + }, + + loading: function(bool){ + this.el[bool ? 'addClass' : 'removeClass']('loading') + }, + + q: function(){ + return this.input.get('value').trim(); + } + +}); + +Block.ShowSearch.Item = new Class({ + + Implements: [Options, Events], + + initialize: function(info, options){ + var self = this; + self.setOptions(options); + + self.info = info; + self.alternative_titles = []; + + self.create(); + }, + + create: function(){ + var self = this, + info = self.info; + + self.el = new Element('div.show_result', { + 'id': info.id + }).adopt( + self.thumbnail = info.images && info.images.poster.length > 0 ? new Element('img.thumbnail', { + 'src': info.images.poster[0], + 'height': null, + 'width': null + }) : null, + self.options_el = new Element('div.options.inlay'), + self.data_container = new Element('div.data', { + 'events': { + 'click': self.showOptions.bind(self) + } + }).adopt( + self.info_container = new Element('div.info').adopt( + new Element('h2').adopt( + self.title = new Element('span.title', { + 'text': info.titles && info.titles.length > 0 ? info.titles[0] : 'Unknown' + }), + self.year = info.year ? new Element('span.year', { + 'text': info.year + }) : null + ) + ) + ) + ) + + if(info.titles) + info.titles.each(function(title){ + self.alternativeTitle({ + 'title': title + }); + }) + }, + + alternativeTitle: function(alternative){ + var self = this; + + self.alternative_titles.include(alternative); + }, + + getTitle: function(){ + var self = this; + try { + return self.info.original_title ? self.info.original_title : self.info.titles[0]; + } + catch(e){ + return 'Unknown'; + } + }, + + get: function(key){ + return this.info[key] + }, + + showOptions: function(){ + var self = this; + + self.createOptions(); + + self.data_container.addClass('open'); + self.el.addEvent('outerClick', self.closeOptions.bind(self)) + + }, + + closeOptions: function(){ + var self = this; + + self.data_container.removeClass('open'); + self.el.removeEvents('outerClick') + }, + + add: function(e){ + var self = this; + + if(e) + (e).preventDefault(); + + self.loadingMask(); + + Api.request('show.add', { + 'data': { + 'identifier': self.info.id, + 'id': self.info.id, + 'type': self.info.type, + 'primary_provider': self.info.primary_provider, + 'title': self.title_select.get('value'), + 'profile_id': self.profile_select.get('value'), + 'category_id': self.category_select.get('value') + }, + 'onComplete': function(json){ + self.options_el.empty(); + self.options_el.adopt( + new Element('div.message', { + 'text': json.added ? 'Show successfully added.' : 'Show didn\'t add properly. Check logs' + }) + ); + self.mask.fade('out'); + + self.fireEvent('added'); + }, + 'onFailure': function(){ + self.options_el.empty(); + self.options_el.adopt( + new Element('div.message', { + 'text': 'Something went wrong, check the logs for more info.' + }) + ); + self.mask.fade('out'); + } + }); + }, + + createOptions: function(){ + var self = this, + info = self.info; + + if(!self.options_el.hasClass('set')){ + + if(self.info.in_library){ + var in_library = []; + self.info.in_library.releases.each(function(release){ + in_library.include(release.quality.label) + }); + } + + self.options_el.grab( + new Element('div', { + 'class': self.info.in_wanted && self.info.in_wanted.profile || 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 + }) : (in_library ? new Element('span.in_library', { + 'text': 'Already in library: ' + in_library.join(', ') + }) : null), + self.title_select = new Element('select', { + 'name': 'title' + }), + self.profile_select = new Element('select', { + 'name': 'profile' + }), + self.category_select = new Element('select', { + 'name': 'category' + }).grab( + new Element('option', {'value': -1, 'text': 'None'}) + ), + self.add_button = new Element('a.button', { + 'text': 'Add', + 'events': { + 'click': self.add.bind(self) + } + }) + ) + ); + + Array.each(self.alternative_titles, function(alt){ + new Element('option', { + 'text': alt.title + }).inject(self.title_select) + }) + + + // Fill categories + var categories = CategoryList.getAll(); + + if(categories.length == 0) + self.category_select.hide(); + else { + self.category_select.show(); + categories.each(function(category){ + new Element('option', { + 'value': category.data.id, + 'text': category.data.label + }).inject(self.category_select); + }); + } + + // Fill profiles + var profiles = Quality.getActiveProfiles(); + if(profiles.length == 1) + self.profile_select.hide(); + + profiles.each(function(profile){ + new Element('option', { + 'value': profile.id ? profile.id : profile.data.id, + 'text': profile.label ? profile.label : profile.data.label + }).inject(self.profile_select) + }); + + 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.add(); + + } + + }, + + loadingMask: function(){ + var self = this; + + self.mask = new Element('div.mask').inject(self.el).fade('hide') + + createSpinner(self.mask) + self.mask.fade('in') + + }, + + toElement: function(){ + return this.el + } + +}); diff --git a/couchpotato/core/media/show/library/__init__.py b/couchpotato/core/media/show/library/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/couchpotato/core/media/show/library/episode/__init__.py b/couchpotato/core/media/show/library/episode/__init__.py new file mode 100644 index 00000000..2ca7dda3 --- /dev/null +++ b/couchpotato/core/media/show/library/episode/__init__.py @@ -0,0 +1,6 @@ +from .main import EpisodeLibraryPlugin + +def start(): + return EpisodeLibraryPlugin() + +config = [] diff --git a/couchpotato/core/media/show/library/episode/main.py b/couchpotato/core/media/show/library/episode/main.py new file mode 100644 index 00000000..99a21f6a --- /dev/null +++ b/couchpotato/core/media/show/library/episode/main.py @@ -0,0 +1,190 @@ +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 string import ascii_letters +import time +import traceback + +log = CPLog(__name__) + + +class EpisodeLibraryPlugin(LibraryBase): + + default_dict = {'titles': {}, 'files':{}} + + def __init__(self): + addEvent('library.add.episode', self.add) + addEvent('library.update.episode', self.update) + addEvent('library.update.episode_release_date', self.updateReleaseDate) + + def add(self, attrs = {}, update_after = True): + type = attrs.get('type', 'episode') + primary_provider = attrs.get('primary_provider', 'thetvdb') + + db = get_session() + parent_identifier = attrs.get('parent_identifier', None) + + parent = None + if parent_identifier: + parent = db.query(Library).filter_by(primary_provider = primary_provider, identifier = attrs.get('parent_identifier')).first() + + l = db.query(Library).filter_by(type = type, identifier = attrs.get('identifier')).first() + if not l: + status = fireEvent('status.get', 'needs_update', single = True) + l = Library( + type = type, + primary_provider = primary_provider, + year = attrs.get('year'), + identifier = attrs.get('identifier'), + plot = toUnicode(attrs.get('plot')), + tagline = toUnicode(attrs.get('tagline')), + status_id = status.get('id'), + info = {}, + parent = parent, + ) + + title = LibraryTitle( + title = toUnicode(attrs.get('title')), + simple_title = self.simplifyTitle(attrs.get('title')), + ) + + l.titles.append(title) + + db.add(l) + db.commit() + + # Update library info + if update_after is not False: + handle = fireEventAsync if update_after is 'async' else fireEvent + handle('library.update.episode', identifier = l.identifier, default_title = toUnicode(attrs.get('title', ''))) + + library_dict = l.to_dict(self.default_dict) + + db.expire_all() + return library_dict + + def update(self, identifier, default_title = '', force = False): + + if self.shuttingDown(): + return + + db = get_session() + library = db.query(Library).filter_by(identifier = identifier).first() + done_status = fireEvent('status.get', 'done', single = True) + + if library: + library_dict = library.to_dict(self.default_dict) + + do_update = True + + # XXX: Fix to be pretty + parent_identifier = None + if library.parent: + parent_identifier = library.parent.identifier + + if library.status_id == done_status.get('id') and not force: + do_update = False + + info = fireEvent('episode.info', merge = True, identifier = identifier, \ + parent_identifier = parent_identifier) + + # Don't need those here + try: del info['in_wanted'] + except: pass + try: del info['in_library'] + except: pass + + if not info or len(info) == 0: + log.error('Could not update, no movie info to work with: %s', identifier) + return False + + # Main info + if do_update: + library.plot = toUnicode(info.get('plot', '')) + library.tagline = toUnicode(info.get('tagline', '')) + library.year = info.get('year', 0) + library.status_id = done_status.get('id') + library.info.update(info) + db.commit() + + # Titles + [db.delete(title) for title in library.titles] + db.commit() + + titles = info.get('titles', []) + log.debug('Adding titles: %s', titles) + counter = 0 + for title in titles: + if not title: + continue + title = toUnicode(title) + t = LibraryTitle( + title = title, + simple_title = self.simplifyTitle(title), + default = (len(default_title) == 0 and counter == 0) or len(titles) == 1 or title.lower() == toUnicode(default_title.lower()) or (toUnicode(default_title) == u'' and toUnicode(titles[0]) == title) + ) + library.titles.append(t) + counter += 1 + + db.commit() + + # Files + images = info.get('images', []) + for image_type in ['poster']: + for image in images.get(image_type, []): + if not isinstance(image, (str, unicode)): + continue + + file_path = fireEvent('file.download', url = image, single = True) + if file_path: + file_obj = fireEvent('file.add', path = file_path, type_tuple = ('image', image_type), single = True) + try: + file_obj = db.query(File).filter_by(id = file_obj.get('id')).one() + library.files.append(file_obj) + db.commit() + + break + except: + log.debug('Failed to attach to library: %s', traceback.format_exc()) + + library_dict = library.to_dict(self.default_dict) + + db.expire_all() + return library_dict + + def updateReleaseDate(self, identifier): + + db = get_session() + library = db.query(Library).filter_by(identifier = identifier).first() + + if not library.info: + library_dict = self.update(identifier, force = True) + dates = library_dict.get('info', {}).get('release_date') + else: + dates = library.info.get('release_date') + + if dates and dates.get('expires', 0) < time.time() or not dates: + dates = fireEvent('movie.release_date', identifier = identifier, merge = True) + library.info.update({'release_date': dates }) + db.commit() + + db.expire_all() + return dates + + + def simplifyTitle(self, title): + + title = toUnicode(title) + + nr_prefix = '' if title[0] in ascii_letters else '#' + title = simplifyString(title) + + for prefix in ['the ']: + if prefix == title[:len(prefix)]: + title = title[len(prefix):] + break + + return nr_prefix + title diff --git a/couchpotato/core/media/show/library/season/__init__.py b/couchpotato/core/media/show/library/season/__init__.py new file mode 100644 index 00000000..6fdab1d9 --- /dev/null +++ b/couchpotato/core/media/show/library/season/__init__.py @@ -0,0 +1,6 @@ +from .main import SeasonLibraryPlugin + +def start(): + return SeasonLibraryPlugin() + +config = [] diff --git a/couchpotato/core/media/show/library/season/main.py b/couchpotato/core/media/show/library/season/main.py new file mode 100644 index 00000000..65bfbb53 --- /dev/null +++ b/couchpotato/core/media/show/library/season/main.py @@ -0,0 +1,190 @@ +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 string import ascii_letters +import time +import traceback + +log = CPLog(__name__) + + +class SeasonLibraryPlugin(LibraryBase): + + default_dict = {'titles': {}, 'files':{}} + + def __init__(self): + addEvent('library.add.season', self.add) + addEvent('library.update.season', self.update) + addEvent('library.update.season_release_date', self.updateReleaseDate) + + def add(self, attrs = {}, update_after = True): + type = attrs.get('type', 'season') + primary_provider = attrs.get('primary_provider', 'thetvdb') + + db = get_session() + parent_identifier = attrs.get('parent_identifier', None) + + parent = None + if parent_identifier: + parent = db.query(Library).filter_by(primary_provider = primary_provider, identifier = attrs.get('parent_identifier')).first() + + l = db.query(Library).filter_by(type = type, identifier = attrs.get('identifier')).first() + if not l: + status = fireEvent('status.get', 'needs_update', single = True) + l = Library( + type = type, + primary_provider = primary_provider, + year = attrs.get('year'), + identifier = attrs.get('identifier'), + plot = toUnicode(attrs.get('plot')), + tagline = toUnicode(attrs.get('tagline')), + status_id = status.get('id'), + info = {}, + parent = parent, + ) + + title = LibraryTitle( + title = toUnicode(attrs.get('title')), + simple_title = self.simplifyTitle(attrs.get('title')), + ) + + l.titles.append(title) + + db.add(l) + db.commit() + + # Update library info + if update_after is not False: + handle = fireEventAsync if update_after is 'async' else fireEvent + handle('library.update.season', identifier = l.identifier, default_title = toUnicode(attrs.get('title', ''))) + + library_dict = l.to_dict(self.default_dict) + + db.expire_all() + return library_dict + + def update(self, identifier, default_title = '', force = False): + + if self.shuttingDown(): + return + + db = get_session() + library = db.query(Library).filter_by(identifier = identifier).first() + done_status = fireEvent('status.get', 'done', single = True) + + if library: + library_dict = library.to_dict(self.default_dict) + + do_update = True + + # XXX: Fix to be pretty + parent_identifier = None + if library.parent: + parent_identifier = library.parent.identifier + + if library.status_id == done_status.get('id') and not force: + do_update = False + + info = fireEvent('season.info', merge = True, identifier = identifier, \ + parent_identifier = parent_identifier) + + # Don't need those here + try: del info['in_wanted'] + except: pass + try: del info['in_library'] + except: pass + + if not info or len(info) == 0: + log.error('Could not update, no movie info to work with: %s', identifier) + return False + + # Main info + if do_update: + library.plot = toUnicode(info.get('plot', '')) + library.tagline = toUnicode(info.get('tagline', '')) + library.year = info.get('year', 0) + library.status_id = done_status.get('id') + library.info.update(info) + db.commit() + + # Titles + [db.delete(title) for title in library.titles] + db.commit() + + titles = info.get('titles', []) + log.debug('Adding titles: %s', titles) + counter = 0 + for title in titles: + if not title: + continue + title = toUnicode(title) + t = LibraryTitle( + title = title, + simple_title = self.simplifyTitle(title), + default = (len(default_title) == 0 and counter == 0) or len(titles) == 1 or title.lower() == toUnicode(default_title.lower()) or (toUnicode(default_title) == u'' and toUnicode(titles[0]) == title) + ) + library.titles.append(t) + counter += 1 + + db.commit() + + # Files + images = info.get('images', []) + for image_type in ['poster']: + for image in images.get(image_type, []): + if not isinstance(image, (str, unicode)): + continue + + file_path = fireEvent('file.download', url = image, single = True) + if file_path: + file_obj = fireEvent('file.add', path = file_path, type_tuple = ('image', image_type), single = True) + try: + file_obj = db.query(File).filter_by(id = file_obj.get('id')).one() + library.files.append(file_obj) + db.commit() + + break + except: + log.debug('Failed to attach to library: %s', traceback.format_exc()) + + library_dict = library.to_dict(self.default_dict) + + db.expire_all() + return library_dict + + def updateReleaseDate(self, identifier): + + db = get_session() + library = db.query(Library).filter_by(identifier = identifier).first() + + if not library.info: + library_dict = self.update(identifier, force = True) + dates = library_dict.get('info', {}).get('release_date') + else: + dates = library.info.get('release_date') + + if dates and dates.get('expires', 0) < time.time() or not dates: + dates = fireEvent('movie.release_date', identifier = identifier, merge = True) + library.info.update({'release_date': dates }) + db.commit() + + db.expire_all() + return dates + + + def simplifyTitle(self, title): + + title = toUnicode(title) + + nr_prefix = '' if title[0] in ascii_letters else '#' + title = simplifyString(title) + + for prefix in ['the ']: + if prefix == title[:len(prefix)]: + title = title[len(prefix):] + break + + return nr_prefix + title diff --git a/couchpotato/core/media/show/library/show/__init__.py b/couchpotato/core/media/show/library/show/__init__.py new file mode 100644 index 00000000..53cf8b56 --- /dev/null +++ b/couchpotato/core/media/show/library/show/__init__.py @@ -0,0 +1,6 @@ +from .main import ShowLibraryPlugin + +def start(): + return ShowLibraryPlugin() + +config = [] diff --git a/couchpotato/core/plugins/library/main.py b/couchpotato/core/media/show/library/show/main.py similarity index 81% rename from couchpotato/core/plugins/library/main.py rename to couchpotato/core/media/show/library/show/main.py index 1842ed70..b8db80c2 100644 --- a/couchpotato/core/plugins/library/main.py +++ b/couchpotato/core/media/show/library/show/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.plugins.base import Plugin from couchpotato.core.settings.model import Library, LibraryTitle, File +from couchpotato.core.media._base.library import LibraryBase from string import ascii_letters import time import traceback @@ -11,29 +11,34 @@ import traceback log = CPLog(__name__) -class LibraryPlugin(Plugin): +class ShowLibraryPlugin(LibraryBase): default_dict = {'titles': {}, 'files':{}} def __init__(self): - addEvent('library.add', self.add) - addEvent('library.update', self.update) - addEvent('library.update_release_date', self.updateReleaseDate) + addEvent('library.add.show', self.add) + addEvent('library.update.show', self.update) + addEvent('library.update.show_release_date', self.updateReleaseDate) def add(self, attrs = {}, update_after = True): - + type = attrs.get('type', 'show') + primary_provider = attrs.get('primary_provider', 'thetvdb') + db = get_session() - - l = db.query(Library).filter_by(identifier = attrs.get('identifier')).first() + + l = db.query(Library).filter_by(type = type, identifier = attrs.get('identifier')).first() if not l: status = fireEvent('status.get', 'needs_update', single = True) l = Library( + type = type, + primary_provider = primary_provider, year = attrs.get('year'), identifier = attrs.get('identifier'), plot = toUnicode(attrs.get('plot')), tagline = toUnicode(attrs.get('tagline')), status_id = status.get('id'), info = {}, + parent = None, ) title = LibraryTitle( @@ -49,7 +54,7 @@ class LibraryPlugin(Plugin): # Update library info if update_after is not False: handle = fireEventAsync if update_after is 'async' else fireEvent - handle('library.update', identifier = l.identifier, default_title = toUnicode(attrs.get('title', ''))) + handle('library.update.show', identifier = l.identifier, default_title = toUnicode(attrs.get('title', ''))) library_dict = l.to_dict(self.default_dict) @@ -70,20 +75,17 @@ class LibraryPlugin(Plugin): do_update = True - if library.status_id == done_status.get('id') and not force: - do_update = False - else: - info = fireEvent('movie.info', merge = True, identifier = identifier) + info = fireEvent('show.info' % library.type, merge = True, identifier = identifier) - # Don't need those here - try: del info['in_wanted'] - except: pass - try: del info['in_library'] - except: pass + # Don't need those here + try: del info['in_wanted'] + except: pass + try: del info['in_library'] + except: pass - if not info or len(info) == 0: - log.error('Could not update, no movie info to work with: %s', identifier) - return False + if not info or len(info) == 0: + log.error('Could not update, no movie info to work with: %s', identifier) + return False # Main info if do_update: diff --git a/couchpotato/core/media/show/searcher/__init__.py b/couchpotato/core/media/show/searcher/__init__.py new file mode 100644 index 00000000..4c9008f9 --- /dev/null +++ b/couchpotato/core/media/show/searcher/__init__.py @@ -0,0 +1,7 @@ +from .main import ShowSearcher +import random + +def start(): + return ShowSearcher() + +config = [] diff --git a/couchpotato/core/media/tv/searcher/main.py b/couchpotato/core/media/show/searcher/main.py similarity index 86% rename from couchpotato/core/media/tv/searcher/main.py rename to couchpotato/core/media/show/searcher/main.py index 07f89632..956490a4 100644 --- a/couchpotato/core/media/tv/searcher/main.py +++ b/couchpotato/core/media/show/searcher/main.py @@ -4,7 +4,7 @@ from couchpotato.core.plugins.base import Plugin log = CPLog(__name__) -class TVSearcher(Plugin): +class ShowSearcher(Plugin): in_progress = False diff --git a/couchpotato/core/media/tv/_base/__init__.py b/couchpotato/core/media/tv/_base/__init__.py deleted file mode 100644 index 91d37089..00000000 --- a/couchpotato/core/media/tv/_base/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .main import TVBase - -def start(): - return TVBase() - -config = [] diff --git a/couchpotato/core/media/tv/_base/main.py b/couchpotato/core/media/tv/_base/main.py deleted file mode 100644 index 1436371c..00000000 --- a/couchpotato/core/media/tv/_base/main.py +++ /dev/null @@ -1,13 +0,0 @@ -from couchpotato.core.logger import CPLog -from couchpotato.core.media import MediaBase - -log = CPLog(__name__) - - -class TVBase(MediaBase): - - identifier = 'tv' - - def __init__(self): - super(TVBase, self).__init__() - diff --git a/couchpotato/core/media/tv/searcher/__init__.py b/couchpotato/core/media/tv/searcher/__init__.py deleted file mode 100644 index bf5dbc22..00000000 --- a/couchpotato/core/media/tv/searcher/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .main import TVSearcher -import random - -def start(): - return TVSearcher() - -config = [] diff --git a/couchpotato/core/plugins/library/__init__.py b/couchpotato/core/plugins/library/__init__.py deleted file mode 100644 index f5970329..00000000 --- a/couchpotato/core/plugins/library/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .main import LibraryPlugin - -def start(): - return LibraryPlugin() - -config = [] diff --git a/couchpotato/core/plugins/manage/main.py b/couchpotato/core/plugins/manage/main.py index 63584636..516cb882 100644 --- a/couchpotato/core/plugins/manage/main.py +++ b/couchpotato/core/plugins/manage/main.py @@ -182,7 +182,7 @@ class Manage(Plugin): # Add it to release and update the info fireEvent('release.add', group = group) - fireEventAsync('library.update', identifier = identifier, on_complete = self.createAfterUpdate(folder, identifier)) + fireEventAsync('library.update.movie', identifier = identifier, on_complete = self.createAfterUpdate(folder, identifier)) else: self.in_progress[folder]['to_go'] = self.in_progress[folder]['to_go'] - 1 diff --git a/couchpotato/core/plugins/renamer/main.py b/couchpotato/core/plugins/renamer/main.py index 8d1a8186..1eb0fd9c 100644 --- a/couchpotato/core/plugins/renamer/main.py +++ b/couchpotato/core/plugins/renamer/main.py @@ -154,7 +154,7 @@ class Renamer(Plugin): continue # Rename the files using the library data else: - group['library'] = fireEvent('library.update', identifier = group['library']['identifier'], single = True) + group['library'] = fireEvent('library.update.movie', identifier = group['library']['identifier'], single = True) if not group['library']: log.error('Could not rename, no library item to work with: %s', group_identifier) continue diff --git a/couchpotato/core/plugins/scanner/main.py b/couchpotato/core/plugins/scanner/main.py index e48d2747..6814f4f6 100644 --- a/couchpotato/core/plugins/scanner/main.py +++ b/couchpotato/core/plugins/scanner/main.py @@ -617,7 +617,7 @@ class Scanner(Plugin): log.debug('Identifier to short to use for search: %s', identifier) if imdb_id: - return fireEvent('library.add', attrs = { + return fireEvent('library.add.movie', attrs = { 'identifier': imdb_id }, update_after = False, single = True) diff --git a/couchpotato/core/providers/metadata/base.py b/couchpotato/core/providers/metadata/base.py index d7de8988..d2393ddc 100644 --- a/couchpotato/core/providers/metadata/base.py +++ b/couchpotato/core/providers/metadata/base.py @@ -24,7 +24,7 @@ class MetaDataBase(Plugin): # Update library to get latest info try: - updated_library = fireEvent('library.update', group['library']['identifier'], force = True, single = True) + updated_library = fireEvent('library.update.movie', group['library']['identifier'], force = True, single = True) group['library'] = mergeDicts(group['library'], updated_library) except: log.error('Failed to update movie, before creating metadata: %s', traceback.format_exc()) diff --git a/couchpotato/core/providers/show/__init__.py b/couchpotato/core/providers/show/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/couchpotato/core/providers/show/_modifier/__init__.py b/couchpotato/core/providers/show/_modifier/__init__.py new file mode 100644 index 00000000..54d59197 --- /dev/null +++ b/couchpotato/core/providers/show/_modifier/__init__.py @@ -0,0 +1,7 @@ +from .main import ShowResultModifier + +def start(): + + return ShowResultModifier() + +config = [] diff --git a/couchpotato/core/providers/show/_modifier/main.py b/couchpotato/core/providers/show/_modifier/main.py new file mode 100644 index 00000000..523e200a --- /dev/null +++ b/couchpotato/core/providers/show/_modifier/main.py @@ -0,0 +1,94 @@ +from couchpotato import get_session +from couchpotato.core.event import addEvent, fireEvent +from couchpotato.core.helpers.variable import mergeDicts, randomString +from couchpotato.core.logger import CPLog +from couchpotato.core.plugins.base import Plugin +from couchpotato.core.settings.model import Library +import copy +import traceback + +log = CPLog(__name__) + + +class ShowResultModifier(Plugin): + + default_info = { + 'tmdb_id': 0, + 'titles': [], + 'original_title': '', + 'year': 0, + 'images': { + 'poster': [], + 'backdrop': [], + 'poster_original': [], + 'backdrop_original': [] + }, + 'runtime': 0, + 'plot': '', + 'tagline': '', + 'imdb': '', + 'genres': [], + } + + def __init__(self): + addEvent('result.modify.show.search', self.combineOnIMDB) + addEvent('result.modify.show.info', self.checkLibrary) + + def combineOnIMDB(self, results): + + temp = {} + order = [] + + # Combine on imdb id + for item in results: + random_string = randomString() + imdb = item.get('imdb', random_string) + imdb = imdb if imdb else random_string + + if not temp.get(imdb): + temp[imdb] = self.getLibraryTags(imdb) + order.append(imdb) + + # Merge dicts + temp[imdb] = mergeDicts(temp[imdb], item) + + # Make it a list again + temp_list = [temp[x] for x in order] + + return temp_list + + def getLibraryTags(self, imdb): + + temp = { + 'in_wanted': False, + 'in_library': False, + } + + # Add release info from current library + db = get_session() + try: + l = db.query(Library).filter_by(identifier = imdb).first() + if l: + + # Statuses + active_status, done_status = fireEvent('status.get', ['active', 'done'], single = True) + + for movie in l.movies: + if movie.status_id == active_status['id']: + temp['in_wanted'] = fireEvent('movie.get', movie.id, single = True) + + for release in movie.releases: + if release.status_id == done_status['id']: + temp['in_library'] = fireEvent('movie.get', movie.id, single = True) + except: + log.error('Tried getting more info on searched movies: %s', traceback.format_exc()) + + return temp + + def checkLibrary(self, result): + + result = mergeDicts(copy.deepcopy(self.default_info), copy.deepcopy(result)) + + if result and result.get('imdb'): + return mergeDicts(result, self.getLibraryTags(result['imdb'])) + return result diff --git a/couchpotato/core/providers/show/base.py b/couchpotato/core/providers/show/base.py new file mode 100644 index 00000000..95ab0dcc --- /dev/null +++ b/couchpotato/core/providers/show/base.py @@ -0,0 +1,5 @@ +from couchpotato.core.providers.base import Provider + + +class ShowProvider(Provider): + type = 'show' diff --git a/couchpotato/core/providers/show/thetvdb/__init__.py b/couchpotato/core/providers/show/thetvdb/__init__.py new file mode 100644 index 00000000..68c0dde4 --- /dev/null +++ b/couchpotato/core/providers/show/thetvdb/__init__.py @@ -0,0 +1,24 @@ +from .main import TheTVDb + +def start(): + return TheTVDb() + +config = [{ + 'name': 'thetvdb', + 'groups': [ + { + 'tab': 'providers', + 'name': 'tmdb', + 'label': 'TheTVDB', + 'hidden': True, + 'description': 'Used for all calls to TheTVDB.', + 'options': [ + { + 'name': 'api_key', + 'default': '7966C02F860586D2', + 'label': 'Api Key', + }, + ], + }, + ], +}] diff --git a/couchpotato/core/providers/show/thetvdb/main.py b/couchpotato/core/providers/show/thetvdb/main.py new file mode 100644 index 00000000..fa19580c --- /dev/null +++ b/couchpotato/core/providers/show/thetvdb/main.py @@ -0,0 +1,394 @@ +from couchpotato.core.event import addEvent +from couchpotato.core.helpers.encoding import simplifyString, toUnicode +from couchpotato.core.logger import CPLog +from couchpotato.core.providers.show.base import ShowProvider +from tvdb_api import tvdb_api, tvdb_exceptions +from datetime import datetime +import traceback + +log = CPLog(__name__) + +# XXX: I return None in alot of functions when there is error or no value; check if I +# should be returning an empty list or dictionary +# XXX: Consider grabbing zips to put less strain on tvdb +# XXX: Consider a cache; not implenented everywhere yet or at all +class TheTVDb(ShowProvider): + + def __init__(self): + #addEvent('show.by_hash', self.byHash) + addEvent('show.search', self.search, priority = 1) + addEvent('show.info', self.getShowInfo, priority = 1) + addEvent('show.episodes', self.getEpisodes, priority = 1) + addEvent('episode.info', self.getEpisodeInfo, priority = 1) + #addEvent('show.info_by_thetvdb', self.getInfoByTheTVDBId) + + # XXX: Load from somewhere else + tvdb_api_parms = { + 'apikey':"7966C02F860586D2", + 'banners':True + } + + self.tvdb = tvdb_api.Tvdb(**tvdb_api_parms) + + #def byHash(self, file): + #''' Find show 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 show by hash: %s', file) + #try: + #raw = tmdb.searchByHashingFile(file) + + #results = [] + #if raw: + #try: + #results = self.parseShow(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 shows known by hash for: %s', file) + #pass + + #return results + + def search(self, q, limit = 12): + ''' Find show by name + show = { 'id': 74713, + 'language': 'en', + 'lid': 7, + 'seriesid': '74713', + 'seriesname': u'Breaking Bad',} + ''' + + if self.isDisabled(): + return False + + search_string = simplifyString(q) + cache_key = 'thetvdb.cache.%s.%s' % (search_string, limit) + results = self.getCache(cache_key) + + if not results: + log.debug('Searching for show: %s', q) + + raw = None + try: + raw = self.tvdb.search(search_string) + + except (tvdb_exceptions.tvdb_error, IOError), e: + log.error('Failed searching TheTVDB for "%s": %s', (search_string, traceback.format_exc())) + return None + + results = [] + if raw: + try: + nr = 0 + + for show in raw: + show = self.tvdb[int(show['id'])] + results.append(self.parseShow(show)) + + nr += 1 + if nr == limit: + break + + log.info('Found: %s', [result['titles'][0] + ' (' + str(result.get('year', 0)) + ')' for result in results]) + + self.setCache(cache_key, results) + return results + #except SyntaxError, e: + # log.error('Failed to parse XML response: %s', e) + # return False + except (tvdb_exceptions.tvdb_error, IOError), e: + log.error('Failed parsing TheTVDB for "%s": %s', (show, traceback.format_exc())) + return False + + return results + + def getEpisodes(self, identifier=None, episode_identifier=None): + """Either return a list of all episodes or a single episode. + If episode_identifer contains an episode to search for it will be returned if found + """ + if not identifier: + return None + + try: + show = self.tvdb[int(identifier)] + except (tvdb_exceptions.tvdb_error, IOError), e: + log.error('Failed parsing TheTVDB for "%s" id "%s": %s', (show, identifier, traceback.format_exc())) + return None + + result = [] + for season in show.values(): + for episode in season.values(): + if episode_identifier: + if episode['id'] == toUnicode(episode_identifier): + return episode + else: + result.append(self.parseEpisode(show, episode)) + + return result + + def getShow(self, identifier = None): + show = None + try: + log.debug('Getting show: %s', identifier) + show = self.tvdb[int(identifier)] + except (tvdb_exceptions.tvdb_error, IOError), e: + log.error('Failed to getShowInfo for show id "%s": %s', (identifier, traceback.format_exc())) + return None + + return show + + def getShowInfo(self, identifier = None): + if not identifier: + return None + + cache_key = 'thetvdb.cache.%s' % identifier + log.debug('Getting showInfo: %s', cache_key) + result = self.getCache(cache_key) or {} + if result: + return result + + show = self.getShow(identifier=identifier) + if show: + result = self.parseShow(show) + self.setCache(cache_key, result) + + return result + + def getEpisodeInfo(self, identifier = None, parent_identifier = None): + if not identifier or not parent_identifier: + return None + + cache_key = 'thetvdb.cache.%s.%s' % (parent_identifier, identifier) + log.debug('Getting EpisodeInfo: %s', cache_key) + result = self.getCache(cache_key) or {} + if result: + return result + + show = self.getShow(identifier = parent_identifier) + if show: + episode = self.getEpisodes(identifier=parent_identifier, episode_identifier=identifier) + + if episode: + result = self.parseEpisode(show, episode) + self.setCache(cache_key, result) + + return result + + #def getInfoByTheTVDBId(self, id = None): + + #cache_key = 'thetvdb.cache.%s' % id + #result = self.getCache(cache_key) + + #if not result: + #result = {} + #show = None + + #try: + #log.debug('Getting info: %s', cache_key) + #show = tmdb.getShowInfo(id = id) + #except: + #pass + + #if show: + #result = self.parseShow(show) + #self.setCache(cache_key, result) + + #return result + + def parseShow(self, show): + """ + show[74713] = { + 'actors': u'|Bryan Cranston|Aaron Paul|Dean Norris|RJ Mitte|Betsy Brandt|Anna Gunn|Laura Fraser|Jesse Plemons|Christopher Cousins|Steven Michael Quezada|Jonathan Banks|Giancarlo Esposito|Bob Odenkirk|', + 'added': None, + 'addedby': None, + 'airs_dayofweek': u'Sunday', + 'airs_time': u'9:00 PM', + 'banner': u'http://thetvdb.com/banners/graphical/81189-g13.jpg', + 'contentrating': u'TV-MA', + 'fanart': u'http://thetvdb.com/banners/fanart/original/81189-28.jpg', + 'firstaired': u'2008-01-20', + 'genre': u'|Crime|Drama|Suspense|', + 'id': u'81189', + 'imdb_id': u'tt0903747', + 'language': u'en', + 'lastupdated': u'1376620212', + 'network': u'AMC', + 'networkid': None, + 'overview': u"Walter White, a struggling high school chemistry teacher is diagnosed with advanced lung cancer. He turns to a life of crime, producing and selling methamphetamine accompanied by a former student, Jesse Pinkman with the aim of securing his family's financial future before he dies.", + 'poster': u'http://thetvdb.com/banners/posters/81189-22.jpg', + 'rating': u'9.3', + 'ratingcount': u'473', + 'runtime': u'60', + 'seriesid': u'74713', + 'seriesname': u'Breaking Bad', + 'status': u'Continuing', + 'zap2it_id': u'SH01009396'} + """ + + # Make sure we have a valid show id, not '' or None + #if len (show['id']) is 0: + # return None + + ## Images + poster = self.getImage(show, type = 'poster', size = 'cover') + backdrop = self.getImage(show, type = 'fanart', size = 'w1280') + #poster_original = self.getImage(show, type = 'poster', size = 'original') + #backdrop_original = self.getImage(show, type = 'backdrop', size = 'original') + + ## Genres + genres = [] if show['genre'] is None else show['genre'].strip('|').split('|') + + ## Year + if show['firstaired']: + year = datetime.strptime(show['firstaired'], '%Y-%m-%d').year + else: + year = None + + show_data = { + 'id': int(show['id']), + 'type': 'show', + 'primary_provider': 'thetvdb', + 'titles': [show['seriesname'], ], + 'original_title': show['seriesname'], + 'images': { + 'poster': [poster] if poster else [], + 'backdrop': [backdrop] if backdrop else [], + 'poster_original': [], + 'backdrop_original': [], + }, + 'imdb': show['imdb_id'], + 'runtime': show['runtime'], + 'released': show['firstaired'], + 'year': year, + 'plot': show['overview'], + 'genres': genres, + } + + show_data = dict((k, v) for k, v in show_data.iteritems() if v) + + ## Add alternative names + #for alt in ['original_name', 'alternative_name']: + #alt_name = toUnicode(show.get(alt)) + #if alt_name and not alt_name in show_data['titles'] and alt_name.lower() != 'none' and alt_name != None: + #show_data['titles'].append(alt_name) + + return show_data + + def parseEpisode(self, show, episode): + """ + ('episodenumber', u'1'), + ('thumb_added', None), + ('rating', u'7.7'), + ('overview', + u'Experienced waitress Max Black meets her new co-worker, former rich-girl Caroline Channing, and puts her skills to the test at an old but re-emerging Brooklyn diner. Despite her initial distaste for Caroline, Max eventually softens and the two team up for a new business venture.'), + ('dvd_episodenumber', None), + ('dvd_discid', None), + ('combined_episodenumber', u'1'), + ('epimgflag', u'7'), + ('id', u'4099506'), + ('seasonid', u'465948'), + ('thumb_height', u'225'), + ('tms_export', u'1374789754'), + ('seasonnumber', u'1'), + ('writer', u'|Michael Patrick King|Whitney Cummings|'), + ('lastupdated', u'1371420338'), + ('filename', u'http://thetvdb.com/banners/episodes/248741/4099506.jpg'), + ('absolute_number', u'1'), + ('ratingcount', u'102'), + ('combined_season', u'1'), + ('thumb_width', u'400'), + ('imdb_id', u'tt1980319'), + ('director', u'James Burrows'), + ('dvd_chapter', None), + ('dvd_season', None), + ('gueststars', + u'|Brooke Lyons|Noah Mills|Shoshana Bush|Cale Hartmann|Adam Korson|Alex Enriquez|Matt Cook|Bill Parks|Eugene Shaw|Sergey Brusilovsky|Greg Lewis|Cocoa Brown|Nick Jameson|'), + ('seriesid', u'248741'), + ('language', u'en'), + ('productioncode', u'296793'), + ('firstaired', u'2011-09-19'), + ('episodename', u'Pilot')] + """ + + ## Images + #poster = self.getImage(episode, type = 'poster', size = 'cover') + #backdrop = self.getImage(episode, type = 'fanart', size = 'w1280') + ##poster_original = self.getImage(episode, type = 'poster', size = 'original') + ##backdrop_original = self.getImage(episode, type = 'backdrop', size = 'original') + poster = episode['filename'] or [] + backdrop = [] + + ## Genres + genres = [] + + plot = "%s - %sx%s - %s" % (show['seriesname'], episode['seasonnumber'], episode['episodenumber'], episode['overview']) + + ## Year + if episode['firstaired']: + year = datetime.strptime(episode['firstaired'], '%Y-%m-%d').year + else: + year = None + + episode_data = { + 'id': int(episode['id']), + 'type': 'episode', + 'primary_provider': 'thetvdb', + 'via_thetvdb': True, + 'thetvdb_id': int(episode['id']), + 'titles': [episode['episodename'], ], + 'original_title': episode['episodename'], + 'images': { + 'poster': [poster] if poster else [], + 'backdrop': [backdrop] if backdrop else [], + 'poster_original': [], + 'backdrop_original': [], + }, + 'imdb': episode['imdb_id'], + 'runtime': None, + 'released': episode['firstaired'], + 'year': year, + 'plot': plot, + 'genres': genres, + 'parent_identifier': show['id'], + } + + episode_data = dict((k, v) for k, v in episode_data.iteritems() if v) + + ## Add alternative names + #for alt in ['original_name', 'alternative_name']: + #alt_name = toUnicode(episode.get(alt)) + #if alt_name and not alt_name in episode_data['titles'] and alt_name.lower() != 'none' and alt_name != None: + #episode_data['titles'].append(alt_name) + + return episode_data + + def getImage(self, show, type = 'poster', size = 'cover'): + """""" + # XXX: Need to implement size + image_url = '' + + for res, res_data in show['_banners'].get(type, {}).items(): + for bid, banner_info in res_data.items(): + image_url = banner_info.get('_bannerpath', '') + break + + return image_url + + def isDisabled(self): + if self.conf('api_key') == '': + log.error('No API key provided.') + True + else: + False diff --git a/couchpotato/core/settings/model.py b/couchpotato/core/settings/model.py index 27373928..5013d3ad 100644 --- a/couchpotato/core/settings/model.py +++ b/couchpotato/core/settings/model.py @@ -5,7 +5,7 @@ from elixir.options import options_defaults, using_options from elixir.relationships import ManyToMany, OneToMany, ManyToOne from sqlalchemy.ext.mutable import Mutable from sqlalchemy.types import Integer, Unicode, UnicodeText, Boolean, String, \ - TypeDecorator + TypeDecorator, Float, BLOB import json import time @@ -71,12 +71,12 @@ class MutableDict(Mutable, dict): MutableDict.associate_with(JsonType) - class Movie(Entity): """Movie Resource a movie could have multiple releases The files belonging to the movie object are global for the whole movie such as trailers, nfo, thumbnails""" + type = Field(String(10), default="movie", index=True) last_edit = Field(Integer, default = lambda: int(time.time()), index = True) library = ManyToOne('Library', cascade = 'delete, delete-orphan', single_parent = True) @@ -90,6 +90,9 @@ class Movie(Entity): class Library(Entity): """""" + # For Movies, CPS uses three: omdbapi (no prio !?), tmdb (prio 2) and couchpotatoapi (prio 1) + type = Field(String(10), default="movie", index=True) + primary_provider = Field(String(10), default="imdb", index=True) year = Field(Integer) identifier = Field(String(20), index = True) @@ -101,6 +104,9 @@ class Library(Entity): movies = OneToMany('Movie', cascade = 'all, delete-orphan') titles = OneToMany('LibraryTitle', cascade = 'all, delete-orphan') files = ManyToMany('File', cascade = 'all, delete-orphan', single_parent = True) + + parent = ManyToOne('Library') + children = OneToMany('Library') class LibraryTitle(Entity): @@ -114,7 +120,7 @@ class LibraryTitle(Entity): language = OneToMany('Language') libraries = ManyToOne('Library') - + class Language(Entity): """""" @@ -170,7 +176,6 @@ class Status(Entity): label = Field(Unicode(20)) releases = OneToMany('Release') - movies = OneToMany('Movie') class Quality(Entity): @@ -219,6 +224,7 @@ class Category(Entity): destination = Field(Unicode(255)) movie = OneToMany('Movie') + destination = Field(Unicode(255)) class ProfileType(Entity): diff --git a/couchpotato/runner.py b/couchpotato/runner.py index 0c0127fa..230cce61 100644 --- a/couchpotato/runner.py +++ b/couchpotato/runner.py @@ -27,6 +27,8 @@ def getOptions(base_path, args): dest = 'config_file', help = 'Absolute or ~/ path of the settings file (default DATA_DIR/settings.conf)') parser.add_argument('--debug', action = 'store_true', dest = 'debug', help = 'Debug mode') + parser.add_argument('--noreloader', action = 'store_false', + dest = 'noreloader', help = 'Reloader mode') parser.add_argument('--console_log', action = 'store_true', dest = 'console_log', help = "Log to console") parser.add_argument('--quiet', action = 'store_true', @@ -131,7 +133,7 @@ def runCouchPotato(options, base_path, args, data_dir = None, log_dir = None, En # Development development = Env.setting('development', default = False, type = 'bool') Env.set('dev', development) - + # Disable logging for some modules for logger_name in ['enzyme', 'guessit', 'subliminal', 'apscheduler']: logging.getLogger(logger_name).setLevel(logging.ERROR) @@ -140,7 +142,7 @@ def runCouchPotato(options, base_path, args, data_dir = None, log_dir = None, En logging.getLogger(logger_name).setLevel(logging.WARNING) # Use reloader - reloader = debug is True and development and not Env.get('desktop') and not options.daemon + reloader = debug is True and development and not Env.get('desktop') and not options.daemon and options.noreloader is True # Logger logger = logging.getLogger() diff --git a/couchpotato/static/scripts/couchpotato.js b/couchpotato/static/scripts/couchpotato.js index fdc9bd10..1c8d4607 100644 --- a/couchpotato/static/scripts/couchpotato.js +++ b/couchpotato/static/scripts/couchpotato.js @@ -71,6 +71,7 @@ new Element('div').adopt( self.block.navigation = new Block.Navigation(self, {}), self.block.search = new Block.Search(self, {}), + self.block.search = new Block.ShowSearch(self, {}), self.block.more = new Block.Menu(self, {'button_class': 'icon2.cog'}) ) ), diff --git a/libs/tvdb_api/.gitignore b/libs/tvdb_api/.gitignore new file mode 100644 index 00000000..e42c383b --- /dev/null +++ b/libs/tvdb_api/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +*.pyc +*.egg-info/* +dist/*.tar.gz diff --git a/libs/tvdb_api/.travis.yml b/libs/tvdb_api/.travis.yml new file mode 100644 index 00000000..36f5e510 --- /dev/null +++ b/libs/tvdb_api/.travis.yml @@ -0,0 +1,9 @@ +language: python +python: + - 2.5 + - 2.6 + - 2.7 + +install: pip install nose + +script: nosetests diff --git a/libs/tvdb_api/MANIFEST.in b/libs/tvdb_api/MANIFEST.in new file mode 100644 index 00000000..bd227aa4 --- /dev/null +++ b/libs/tvdb_api/MANIFEST.in @@ -0,0 +1,4 @@ +include UNLICENSE +include readme.md +include tests/*.py +include Rakefile diff --git a/libs/tvdb_api/Rakefile b/libs/tvdb_api/Rakefile new file mode 100644 index 00000000..561deb70 --- /dev/null +++ b/libs/tvdb_api/Rakefile @@ -0,0 +1,103 @@ +require 'fileutils' + +task :default => [:clean] + +task :clean do + [".", "tests"].each do |cd| + puts "Cleaning directory #{cd}" + Dir.new(cd).each do |t| + if t =~ /.*\.pyc$/ + puts "Removing #{File.join(cd, t)}" + File.delete(File.join(cd, t)) + end + end + end +end + +desc "Upversion files" +task :upversion do + puts "Upversioning" + + Dir.glob("*.py").each do |filename| + f = File.new(filename, File::RDWR) + contents = f.read() + + contents.gsub!(/__version__ = ".+?"/){|m| + cur_version = m.scan(/\d+\.\d+/)[0].to_f + new_version = cur_version + 0.1 + + puts "Current version: #{cur_version}" + puts "New version: #{new_version}" + + new_line = "__version__ = \"#{new_version}\"" + + puts "Old line: #{m}" + puts "New line: #{new_line}" + + m = new_line + } + + puts contents[0] + + f.truncate(0) # empty the existing file + f.seek(0) + f.write(contents.to_s) # write modified file + f.close() + end +end + +desc "Upload current version to PyPi" +task :topypi => :test do + cur_file = File.open("tvdb_api.py").read() + tvdb_api_version = cur_file.scan(/__version__ = "(.*)"/) + tvdb_api_version = tvdb_api_version[0][0].to_f + + puts "Build sdist and send tvdb_api v#{tvdb_api_version} to PyPi?" + if $stdin.gets.chomp == "y" + puts "Sending source-dist (sdist) to PyPi" + + if system("python setup.py sdist register upload") + puts "tvdb_api uploaded!" + end + + else + puts "Cancelled" + end +end + +desc "Profile by running unittests" +task :profile do + cd "tests" + puts "Profiling.." + `python -m cProfile -o prof_runtest.prof runtests.py` + puts "Converting prof to dot" + `python gprof2dot.py -o prof_runtest.dot -f pstats prof_runtest.prof` + puts "Generating graph" + `~/Applications/dev/graphviz.app/Contents/macOS/dot -Tpng -o profile.png prof_runtest.dot -Gbgcolor=black` + puts "Cleanup" + rm "prof_runtest.dot" + rm "prof_runtest.prof" +end + +task :test do + puts "Nosetest'ing" + if not system("nosetests -v --with-doctest") + raise "Test failed!" + end + + puts "Doctesting *.py (excluding setup.py)" + Dir.glob("*.py").select{|e| ! e.match(/setup.py/)}.each do |filename| + if filename =~ /^setup\.py/ + skip + end + puts "Doctesting #{filename}" + if not system("python", "-m", "doctest", filename) + raise "Failed doctest" + end + end + + puts "Doctesting readme.md" + if not system("python", "-m", "doctest", "readme.md") + raise "Doctest" + end +end diff --git a/libs/tvdb_api/UNLICENSE b/libs/tvdb_api/UNLICENSE new file mode 100644 index 00000000..c4205d41 --- /dev/null +++ b/libs/tvdb_api/UNLICENSE @@ -0,0 +1,26 @@ +Copyright 2011-2012 Ben Dickson (dbr) + +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/libs/tvdb_api/__init__.py b/libs/tvdb_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libs/tvdb_api/readme.md b/libs/tvdb_api/readme.md new file mode 100644 index 00000000..a34726e5 --- /dev/null +++ b/libs/tvdb_api/readme.md @@ -0,0 +1,109 @@ +# `tvdb_api` + +`tvdb_api` is an easy to use interface to [thetvdb.com][tvdb] + +`tvnamer` has moved to a separate repository: [github.com/dbr/tvnamer][tvnamer] - it is a utility which uses `tvdb_api` to rename files from `some.show.s01e03.blah.abc.avi` to `Some Show - [01x03] - The Episode Name.avi` (which works by getting the episode name from `tvdb_api`) + +[![Build Status](https://secure.travis-ci.org/dbr/tvdb_api.png?branch=master)](http://travis-ci.org/dbr/tvdb_api) + +## To install + +You can easily install `tvdb_api` via `easy_install` + + easy_install tvdb_api + +You may need to use sudo, depending on your setup: + + sudo easy_install tvdb_api + +The [`tvnamer`][tvnamer] command-line tool can also be installed via `easy_install`, this installs `tvdb_api` as a dependancy: + + easy_install tvnamer + + +## Basic usage + + import tvdb_api + t = tvdb_api.Tvdb() + episode = t['My Name Is Earl'][1][3] # get season 1, episode 3 of show + print episode['episodename'] # Print episode name + +## Advanced usage + +Most of the documentation is in docstrings. The examples are tested (using doctest) so will always be up to date and working. + +The docstring for `Tvdb.__init__` lists all initialisation arguments, including support for non-English searches, custom "Select Series" interfaces and enabling the retrieval of banners and extended actor information. You can also override the default API key using `apikey`, recommended if you're using `tvdb_api` in a larger script or application + +### Exceptions + +There are several exceptions you may catch, these can be imported from `tvdb_api`: + +- `tvdb_error` - this is raised when there is an error communicating with [thetvdb.com][tvdb] (a network error most commonly) +- `tvdb_userabort` - raised when a user aborts the Select Series dialog (by `ctrl+c`, or entering `q`) +- `tvdb_shownotfound` - raised when `t['show name']` cannot find anything +- `tvdb_seasonnotfound` - raised when the requested series (`t['show name][99]`) does not exist +- `tvdb_episodenotfound` - raised when the requested episode (`t['show name][1][99]`) does not exist. +- `tvdb_attributenotfound` - raised when the requested attribute is not found (`t['show name']['an attribute']`, `t['show name'][1]['an attribute']`, or ``t['show name'][1][1]['an attribute']``) + +### Series data + +All data exposed by [thetvdb.com][tvdb] is accessible via the `Show` class. A Show is retrieved by doing.. + + >>> import tvdb_api + >>> t = tvdb_api.Tvdb() + >>> show = t['scrubs'] + >>> type(show) + + +For example, to find out what network Scrubs is aired: + + >>> t['scrubs']['network'] + u'ABC' + +The data is stored in an attribute named `data`, within the Show instance: + + >>> t['scrubs'].data.keys() + ['networkid', 'rating', 'airs_dayofweek', 'contentrating', 'seriesname', 'id', 'airs_time', 'network', 'fanart', 'lastupdated', 'actors', 'ratingcount', 'status', 'added', 'poster', 'imdb_id', 'genre', 'banner', 'seriesid', 'language', 'zap2it_id', 'addedby', 'firstaired', 'runtime', 'overview'] + +Although each element is also accessible via `t['scrubs']` for ease-of-use: + + >>> t['scrubs']['rating'] + u'9.0' + +This is the recommended way of retrieving "one-off" data (for example, if you are only interested in "seriesname"). If you wish to iterate over all data, or check if a particular show has a specific piece of data, use the `data` attribute, + + >>> 'rating' in t['scrubs'].data + True + +### Banners and actors + +Since banners and actors are separate XML files, retrieving them by default is undesirable. If you wish to retrieve banners (and other fanart), use the `banners` Tvdb initialisation argument: + + >>> from tvdb_api import Tvdb + >>> t = Tvdb(banners = True) + +Then access the data using a `Show`'s `_banner` key: + + >>> t['scrubs']['_banners'].keys() + ['fanart', 'poster', 'series', 'season'] + +The banner data structure will be improved in future versions. + +Extended actor data is accessible similarly: + + >>> t = Tvdb(actors = True) + >>> actors = t['scrubs']['_actors'] + >>> actors[0] + + >>> actors[0].keys() + ['sortorder', 'image', 'role', 'id', 'name'] + >>> actors[0]['role'] + u'Dr. John Michael "J.D." Dorian' + +Remember a simple list of actors is accessible via the default Show data: + + >>> t['scrubs']['actors'] + u'|Zach Braff|Donald Faison|Sarah Chalke|Christa Miller|Aloma Wright|Robert Maschio|Sam Lloyd|Neil Flynn|Ken Jenkins|Judy Reyes|John C. McGinley|Travis Schuldt|Johnny Kastl|Heather Graham|Michael Mosley|Kerry Bish\xe9|Dave Franco|Eliza Coupe|' + +[tvdb]: http://thetvdb.com +[tvnamer]: http://github.com/dbr/tvnamer diff --git a/libs/tvdb_api/setup.py b/libs/tvdb_api/setup.py new file mode 100644 index 00000000..18eb7d1a --- /dev/null +++ b/libs/tvdb_api/setup.py @@ -0,0 +1,35 @@ +from setuptools import setup +setup( +name = 'tvdb_api', +version='1.8.2', + +author='dbr/Ben', +description='Interface to thetvdb.com', +url='http://github.com/dbr/tvdb_api/tree/master', +license='unlicense', + +long_description="""\ +An easy to use API interface to TheTVDB.com +Basic usage is: + +>>> import tvdb_api +>>> t = tvdb_api.Tvdb() +>>> ep = t['My Name Is Earl'][1][22] +>>> ep + +>>> ep['episodename'] +u'Stole a Badge' +""", + +py_modules = ['tvdb_api', 'tvdb_ui', 'tvdb_exceptions', 'tvdb_cache'], + +classifiers=[ + "Intended Audience :: Developers", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Topic :: Multimedia", + "Topic :: Utilities", + "Topic :: Software Development :: Libraries :: Python Modules", +] +) diff --git a/libs/tvdb_api/tests/gprof2dot.py b/libs/tvdb_api/tests/gprof2dot.py new file mode 100644 index 00000000..4503ec7a --- /dev/null +++ b/libs/tvdb_api/tests/gprof2dot.py @@ -0,0 +1,1638 @@ +#!/usr/bin/env python +# +# Copyright 2008 Jose Fonseca +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +"""Generate a dot graph from the output of several profilers.""" + +__author__ = "Jose Fonseca" + +__version__ = "1.0" + + +import sys +import math +import os.path +import re +import textwrap +import optparse + + +try: + # Debugging helper module + import debug +except ImportError: + pass + + +def percentage(p): + return "%.02f%%" % (p*100.0,) + +def add(a, b): + return a + b + +def equal(a, b): + if a == b: + return a + else: + return None + +def fail(a, b): + assert False + + +def ratio(numerator, denominator): + numerator = float(numerator) + denominator = float(denominator) + assert 0.0 <= numerator + assert numerator <= denominator + try: + return numerator/denominator + except ZeroDivisionError: + # 0/0 is undefined, but 1.0 yields more useful results + return 1.0 + + +class UndefinedEvent(Exception): + """Raised when attempting to get an event which is undefined.""" + + def __init__(self, event): + Exception.__init__(self) + self.event = event + + def __str__(self): + return 'unspecified event %s' % self.event.name + + +class Event(object): + """Describe a kind of event, and its basic operations.""" + + def __init__(self, name, null, aggregator, formatter = str): + self.name = name + self._null = null + self._aggregator = aggregator + self._formatter = formatter + + def __eq__(self, other): + return self is other + + def __hash__(self): + return id(self) + + def null(self): + return self._null + + def aggregate(self, val1, val2): + """Aggregate two event values.""" + assert val1 is not None + assert val2 is not None + return self._aggregator(val1, val2) + + def format(self, val): + """Format an event value.""" + assert val is not None + return self._formatter(val) + + +MODULE = Event("Module", None, equal) +PROCESS = Event("Process", None, equal) + +CALLS = Event("Calls", 0, add) +SAMPLES = Event("Samples", 0, add) + +TIME = Event("Time", 0.0, add, lambda x: '(' + str(x) + ')') +TIME_RATIO = Event("Time ratio", 0.0, add, lambda x: '(' + percentage(x) + ')') +TOTAL_TIME = Event("Total time", 0.0, fail) +TOTAL_TIME_RATIO = Event("Total time ratio", 0.0, fail, percentage) + +CALL_RATIO = Event("Call ratio", 0.0, add, percentage) + +PRUNE_RATIO = Event("Prune ratio", 0.0, add, percentage) + + +class Object(object): + """Base class for all objects in profile which can store events.""" + + def __init__(self, events=None): + if events is None: + self.events = {} + else: + self.events = events + + def __hash__(self): + return id(self) + + def __eq__(self, other): + return self is other + + def __contains__(self, event): + return event in self.events + + def __getitem__(self, event): + try: + return self.events[event] + except KeyError: + raise UndefinedEvent(event) + + def __setitem__(self, event, value): + if value is None: + if event in self.events: + del self.events[event] + else: + self.events[event] = value + + +class Call(Object): + """A call between functions. + + There should be at most one call object for every pair of functions. + """ + + def __init__(self, callee_id): + Object.__init__(self) + self.callee_id = callee_id + + +class Function(Object): + """A function.""" + + def __init__(self, id, name): + Object.__init__(self) + self.id = id + self.name = name + self.calls = {} + self.cycle = None + + def add_call(self, call): + if call.callee_id in self.calls: + sys.stderr.write('warning: overwriting call from function %s to %s\n' % (str(self.id), str(call.callee_id))) + self.calls[call.callee_id] = call + + # TODO: write utility functions + + def __repr__(self): + return self.name + + +class Cycle(Object): + """A cycle made from recursive function calls.""" + + def __init__(self): + Object.__init__(self) + # XXX: Do cycles need an id? + self.functions = set() + + def add_function(self, function): + assert function not in self.functions + self.functions.add(function) + # XXX: Aggregate events? + if function.cycle is not None: + for other in function.cycle.functions: + if function not in self.functions: + self.add_function(other) + function.cycle = self + + +class Profile(Object): + """The whole profile.""" + + def __init__(self): + Object.__init__(self) + self.functions = {} + self.cycles = [] + + def add_function(self, function): + if function.id in self.functions: + sys.stderr.write('warning: overwriting function %s (id %s)\n' % (function.name, str(function.id))) + self.functions[function.id] = function + + def add_cycle(self, cycle): + self.cycles.append(cycle) + + def validate(self): + """Validate the edges.""" + + for function in self.functions.itervalues(): + for callee_id in function.calls.keys(): + assert function.calls[callee_id].callee_id == callee_id + if callee_id not in self.functions: + sys.stderr.write('warning: call to undefined function %s from function %s\n' % (str(callee_id), function.name)) + del function.calls[callee_id] + + def find_cycles(self): + """Find cycles using Tarjan's strongly connected components algorithm.""" + + # Apply the Tarjan's algorithm successively until all functions are visited + visited = set() + for function in self.functions.itervalues(): + if function not in visited: + self._tarjan(function, 0, [], {}, {}, visited) + cycles = [] + for function in self.functions.itervalues(): + if function.cycle is not None and function.cycle not in cycles: + cycles.append(function.cycle) + self.cycles = cycles + if 0: + for cycle in cycles: + sys.stderr.write("Cycle:\n") + for member in cycle.functions: + sys.stderr.write("\t%s\n" % member.name) + + def _tarjan(self, function, order, stack, orders, lowlinks, visited): + """Tarjan's strongly connected components algorithm. + + See also: + - http://en.wikipedia.org/wiki/Tarjan's_strongly_connected_components_algorithm + """ + + visited.add(function) + orders[function] = order + lowlinks[function] = order + order += 1 + pos = len(stack) + stack.append(function) + for call in function.calls.itervalues(): + callee = self.functions[call.callee_id] + # TODO: use a set to optimize lookup + if callee not in orders: + order = self._tarjan(callee, order, stack, orders, lowlinks, visited) + lowlinks[function] = min(lowlinks[function], lowlinks[callee]) + elif callee in stack: + lowlinks[function] = min(lowlinks[function], orders[callee]) + if lowlinks[function] == orders[function]: + # Strongly connected component found + members = stack[pos:] + del stack[pos:] + if len(members) > 1: + cycle = Cycle() + for member in members: + cycle.add_function(member) + return order + + def call_ratios(self, event): + # Aggregate for incoming calls + cycle_totals = {} + for cycle in self.cycles: + cycle_totals[cycle] = 0.0 + function_totals = {} + for function in self.functions.itervalues(): + function_totals[function] = 0.0 + for function in self.functions.itervalues(): + for call in function.calls.itervalues(): + if call.callee_id != function.id: + callee = self.functions[call.callee_id] + function_totals[callee] += call[event] + if callee.cycle is not None and callee.cycle is not function.cycle: + cycle_totals[callee.cycle] += call[event] + + # Compute the ratios + for function in self.functions.itervalues(): + for call in function.calls.itervalues(): + assert CALL_RATIO not in call + if call.callee_id != function.id: + callee = self.functions[call.callee_id] + if callee.cycle is not None and callee.cycle is not function.cycle: + total = cycle_totals[callee.cycle] + else: + total = function_totals[callee] + call[CALL_RATIO] = ratio(call[event], total) + + def integrate(self, outevent, inevent): + """Propagate function time ratio allong the function calls. + + Must be called after finding the cycles. + + See also: + - http://citeseer.ist.psu.edu/graham82gprof.html + """ + + # Sanity checking + assert outevent not in self + for function in self.functions.itervalues(): + assert outevent not in function + assert inevent in function + for call in function.calls.itervalues(): + assert outevent not in call + if call.callee_id != function.id: + assert CALL_RATIO in call + + # Aggregate the input for each cycle + for cycle in self.cycles: + total = inevent.null() + for function in self.functions.itervalues(): + total = inevent.aggregate(total, function[inevent]) + self[inevent] = total + + # Integrate along the edges + total = inevent.null() + for function in self.functions.itervalues(): + total = inevent.aggregate(total, function[inevent]) + self._integrate_function(function, outevent, inevent) + self[outevent] = total + + def _integrate_function(self, function, outevent, inevent): + if function.cycle is not None: + return self._integrate_cycle(function.cycle, outevent, inevent) + else: + if outevent not in function: + total = function[inevent] + for call in function.calls.itervalues(): + if call.callee_id != function.id: + total += self._integrate_call(call, outevent, inevent) + function[outevent] = total + return function[outevent] + + def _integrate_call(self, call, outevent, inevent): + assert outevent not in call + assert CALL_RATIO in call + callee = self.functions[call.callee_id] + subtotal = call[CALL_RATIO]*self._integrate_function(callee, outevent, inevent) + call[outevent] = subtotal + return subtotal + + def _integrate_cycle(self, cycle, outevent, inevent): + if outevent not in cycle: + + total = inevent.null() + for member in cycle.functions: + subtotal = member[inevent] + for call in member.calls.itervalues(): + callee = self.functions[call.callee_id] + if callee.cycle is not cycle: + subtotal += self._integrate_call(call, outevent, inevent) + total += subtotal + cycle[outevent] = total + + callees = {} + for function in self.functions.itervalues(): + if function.cycle is not cycle: + for call in function.calls.itervalues(): + callee = self.functions[call.callee_id] + if callee.cycle is cycle: + try: + callees[callee] += call[CALL_RATIO] + except KeyError: + callees[callee] = call[CALL_RATIO] + + for callee, call_ratio in callees.iteritems(): + ranks = {} + call_ratios = {} + partials = {} + self._rank_cycle_function(cycle, callee, 0, ranks) + self._call_ratios_cycle(cycle, callee, ranks, call_ratios, set()) + partial = self._integrate_cycle_function(cycle, callee, call_ratio, partials, ranks, call_ratios, outevent, inevent) + assert partial == max(partials.values()) + assert not total or abs(1.0 - partial/(call_ratio*total)) <= 0.001 + + return cycle[outevent] + + def _rank_cycle_function(self, cycle, function, rank, ranks): + if function not in ranks or ranks[function] > rank: + ranks[function] = rank + for call in function.calls.itervalues(): + if call.callee_id != function.id: + callee = self.functions[call.callee_id] + if callee.cycle is cycle: + self._rank_cycle_function(cycle, callee, rank + 1, ranks) + + def _call_ratios_cycle(self, cycle, function, ranks, call_ratios, visited): + if function not in visited: + visited.add(function) + for call in function.calls.itervalues(): + if call.callee_id != function.id: + callee = self.functions[call.callee_id] + if callee.cycle is cycle: + if ranks[callee] > ranks[function]: + call_ratios[callee] = call_ratios.get(callee, 0.0) + call[CALL_RATIO] + self._call_ratios_cycle(cycle, callee, ranks, call_ratios, visited) + + def _integrate_cycle_function(self, cycle, function, partial_ratio, partials, ranks, call_ratios, outevent, inevent): + if function not in partials: + partial = partial_ratio*function[inevent] + for call in function.calls.itervalues(): + if call.callee_id != function.id: + callee = self.functions[call.callee_id] + if callee.cycle is not cycle: + assert outevent in call + partial += partial_ratio*call[outevent] + else: + if ranks[callee] > ranks[function]: + callee_partial = self._integrate_cycle_function(cycle, callee, partial_ratio, partials, ranks, call_ratios, outevent, inevent) + call_ratio = ratio(call[CALL_RATIO], call_ratios[callee]) + call_partial = call_ratio*callee_partial + try: + call[outevent] += call_partial + except UndefinedEvent: + call[outevent] = call_partial + partial += call_partial + partials[function] = partial + try: + function[outevent] += partial + except UndefinedEvent: + function[outevent] = partial + return partials[function] + + def aggregate(self, event): + """Aggregate an event for the whole profile.""" + + total = event.null() + for function in self.functions.itervalues(): + try: + total = event.aggregate(total, function[event]) + except UndefinedEvent: + return + self[event] = total + + def ratio(self, outevent, inevent): + assert outevent not in self + assert inevent in self + for function in self.functions.itervalues(): + assert outevent not in function + assert inevent in function + function[outevent] = ratio(function[inevent], self[inevent]) + for call in function.calls.itervalues(): + assert outevent not in call + if inevent in call: + call[outevent] = ratio(call[inevent], self[inevent]) + self[outevent] = 1.0 + + def prune(self, node_thres, edge_thres): + """Prune the profile""" + + # compute the prune ratios + for function in self.functions.itervalues(): + try: + function[PRUNE_RATIO] = function[TOTAL_TIME_RATIO] + except UndefinedEvent: + pass + + for call in function.calls.itervalues(): + callee = self.functions[call.callee_id] + + if TOTAL_TIME_RATIO in call: + # handle exact cases first + call[PRUNE_RATIO] = call[TOTAL_TIME_RATIO] + else: + try: + # make a safe estimate + call[PRUNE_RATIO] = min(function[TOTAL_TIME_RATIO], callee[TOTAL_TIME_RATIO]) + except UndefinedEvent: + pass + + # prune the nodes + for function_id in self.functions.keys(): + function = self.functions[function_id] + try: + if function[PRUNE_RATIO] < node_thres: + del self.functions[function_id] + except UndefinedEvent: + pass + + # prune the egdes + for function in self.functions.itervalues(): + for callee_id in function.calls.keys(): + call = function.calls[callee_id] + try: + if callee_id not in self.functions or call[PRUNE_RATIO] < edge_thres: + del function.calls[callee_id] + except UndefinedEvent: + pass + + def dump(self): + for function in self.functions.itervalues(): + sys.stderr.write('Function %s:\n' % (function.name,)) + self._dump_events(function.events) + for call in function.calls.itervalues(): + callee = self.functions[call.callee_id] + sys.stderr.write(' Call %s:\n' % (callee.name,)) + self._dump_events(call.events) + + def _dump_events(self, events): + for event, value in events.iteritems(): + sys.stderr.write(' %s: %s\n' % (event.name, event.format(value))) + + +class Struct: + """Masquerade a dictionary with a structure-like behavior.""" + + def __init__(self, attrs = None): + if attrs is None: + attrs = {} + self.__dict__['_attrs'] = attrs + + def __getattr__(self, name): + try: + return self._attrs[name] + except KeyError: + raise AttributeError(name) + + def __setattr__(self, name, value): + self._attrs[name] = value + + def __str__(self): + return str(self._attrs) + + def __repr__(self): + return repr(self._attrs) + + +class ParseError(Exception): + """Raised when parsing to signal mismatches.""" + + def __init__(self, msg, line): + self.msg = msg + # TODO: store more source line information + self.line = line + + def __str__(self): + return '%s: %r' % (self.msg, self.line) + + +class Parser: + """Parser interface.""" + + def __init__(self): + pass + + def parse(self): + raise NotImplementedError + + +class LineParser(Parser): + """Base class for parsers that read line-based formats.""" + + def __init__(self, file): + Parser.__init__(self) + self._file = file + self.__line = None + self.__eof = False + + def readline(self): + line = self._file.readline() + if not line: + self.__line = '' + self.__eof = True + self.__line = line.rstrip('\r\n') + + def lookahead(self): + assert self.__line is not None + return self.__line + + def consume(self): + assert self.__line is not None + line = self.__line + self.readline() + return line + + def eof(self): + assert self.__line is not None + return self.__eof + + +class GprofParser(Parser): + """Parser for GNU gprof output. + + See also: + - Chapter "Interpreting gprof's Output" from the GNU gprof manual + http://sourceware.org/binutils/docs-2.18/gprof/Call-Graph.html#Call-Graph + - File "cg_print.c" from the GNU gprof source code + http://sourceware.org/cgi-bin/cvsweb.cgi/~checkout~/src/gprof/cg_print.c?rev=1.12&cvsroot=src + """ + + def __init__(self, fp): + Parser.__init__(self) + self.fp = fp + self.functions = {} + self.cycles = {} + + def readline(self): + line = self.fp.readline() + if not line: + sys.stderr.write('error: unexpected end of file\n') + sys.exit(1) + line = line.rstrip('\r\n') + return line + + _int_re = re.compile(r'^\d+$') + _float_re = re.compile(r'^\d+\.\d+$') + + def translate(self, mo): + """Extract a structure from a match object, while translating the types in the process.""" + attrs = {} + groupdict = mo.groupdict() + for name, value in groupdict.iteritems(): + if value is None: + value = None + elif self._int_re.match(value): + value = int(value) + elif self._float_re.match(value): + value = float(value) + attrs[name] = (value) + return Struct(attrs) + + _cg_header_re = re.compile( + # original gprof header + r'^\s+called/total\s+parents\s*$|' + + r'^index\s+%time\s+self\s+descendents\s+called\+self\s+name\s+index\s*$|' + + r'^\s+called/total\s+children\s*$|' + + # GNU gprof header + r'^index\s+%\s+time\s+self\s+children\s+called\s+name\s*$' + ) + + _cg_ignore_re = re.compile( + # spontaneous + r'^\s+\s*$|' + # internal calls (such as "mcount") + r'^.*\((\d+)\)$' + ) + + _cg_primary_re = re.compile( + r'^\[(?P\d+)\]' + + r'\s+(?P\d+\.\d+)' + + r'\s+(?P\d+\.\d+)' + + r'\s+(?P\d+\.\d+)' + + r'\s+(?:(?P\d+)(?:\+(?P\d+))?)?' + + r'\s+(?P\S.*?)' + + r'(?:\s+\d+)>)?' + + r'\s\[(\d+)\]$' + ) + + _cg_parent_re = re.compile( + r'^\s+(?P\d+\.\d+)?' + + r'\s+(?P\d+\.\d+)?' + + r'\s+(?P\d+)(?:/(?P\d+))?' + + r'\s+(?P\S.*?)' + + r'(?:\s+\d+)>)?' + + r'\s\[(?P\d+)\]$' + ) + + _cg_child_re = _cg_parent_re + + _cg_cycle_header_re = re.compile( + r'^\[(?P\d+)\]' + + r'\s+(?P\d+\.\d+)' + + r'\s+(?P\d+\.\d+)' + + r'\s+(?P\d+\.\d+)' + + r'\s+(?:(?P\d+)(?:\+(?P\d+))?)?' + + r'\s+\d+)\sas\sa\swhole>' + + r'\s\[(\d+)\]$' + ) + + _cg_cycle_member_re = re.compile( + r'^\s+(?P\d+\.\d+)?' + + r'\s+(?P\d+\.\d+)?' + + r'\s+(?P\d+)(?:\+(?P\d+))?' + + r'\s+(?P\S.*?)' + + r'(?:\s+\d+)>)?' + + r'\s\[(?P\d+)\]$' + ) + + _cg_sep_re = re.compile(r'^--+$') + + def parse_function_entry(self, lines): + parents = [] + children = [] + + while True: + if not lines: + sys.stderr.write('warning: unexpected end of entry\n') + line = lines.pop(0) + if line.startswith('['): + break + + # read function parent line + mo = self._cg_parent_re.match(line) + if not mo: + if self._cg_ignore_re.match(line): + continue + sys.stderr.write('warning: unrecognized call graph entry: %r\n' % line) + else: + parent = self.translate(mo) + parents.append(parent) + + # read primary line + mo = self._cg_primary_re.match(line) + if not mo: + sys.stderr.write('warning: unrecognized call graph entry: %r\n' % line) + return + else: + function = self.translate(mo) + + while lines: + line = lines.pop(0) + + # read function subroutine line + mo = self._cg_child_re.match(line) + if not mo: + if self._cg_ignore_re.match(line): + continue + sys.stderr.write('warning: unrecognized call graph entry: %r\n' % line) + else: + child = self.translate(mo) + children.append(child) + + function.parents = parents + function.children = children + + self.functions[function.index] = function + + def parse_cycle_entry(self, lines): + + # read cycle header line + line = lines[0] + mo = self._cg_cycle_header_re.match(line) + if not mo: + sys.stderr.write('warning: unrecognized call graph entry: %r\n' % line) + return + cycle = self.translate(mo) + + # read cycle member lines + cycle.functions = [] + for line in lines[1:]: + mo = self._cg_cycle_member_re.match(line) + if not mo: + sys.stderr.write('warning: unrecognized call graph entry: %r\n' % line) + continue + call = self.translate(mo) + cycle.functions.append(call) + + self.cycles[cycle.cycle] = cycle + + def parse_cg_entry(self, lines): + if lines[0].startswith("["): + self.parse_cycle_entry(lines) + else: + self.parse_function_entry(lines) + + def parse_cg(self): + """Parse the call graph.""" + + # skip call graph header + while not self._cg_header_re.match(self.readline()): + pass + line = self.readline() + while self._cg_header_re.match(line): + line = self.readline() + + # process call graph entries + entry_lines = [] + while line != '\014': # form feed + if line and not line.isspace(): + if self._cg_sep_re.match(line): + self.parse_cg_entry(entry_lines) + entry_lines = [] + else: + entry_lines.append(line) + line = self.readline() + + def parse(self): + self.parse_cg() + self.fp.close() + + profile = Profile() + profile[TIME] = 0.0 + + cycles = {} + for index in self.cycles.iterkeys(): + cycles[index] = Cycle() + + for entry in self.functions.itervalues(): + # populate the function + function = Function(entry.index, entry.name) + function[TIME] = entry.self + if entry.called is not None: + function[CALLS] = entry.called + if entry.called_self is not None: + call = Call(entry.index) + call[CALLS] = entry.called_self + function[CALLS] += entry.called_self + + # populate the function calls + for child in entry.children: + call = Call(child.index) + + assert child.called is not None + call[CALLS] = child.called + + if child.index not in self.functions: + # NOTE: functions that were never called but were discovered by gprof's + # static call graph analysis dont have a call graph entry so we need + # to add them here + missing = Function(child.index, child.name) + function[TIME] = 0.0 + function[CALLS] = 0 + profile.add_function(missing) + + function.add_call(call) + + profile.add_function(function) + + if entry.cycle is not None: + cycles[entry.cycle].add_function(function) + + profile[TIME] = profile[TIME] + function[TIME] + + for cycle in cycles.itervalues(): + profile.add_cycle(cycle) + + # Compute derived events + profile.validate() + profile.ratio(TIME_RATIO, TIME) + profile.call_ratios(CALLS) + profile.integrate(TOTAL_TIME, TIME) + profile.ratio(TOTAL_TIME_RATIO, TOTAL_TIME) + + return profile + + +class OprofileParser(LineParser): + """Parser for oprofile callgraph output. + + See also: + - http://oprofile.sourceforge.net/doc/opreport.html#opreport-callgraph + """ + + _fields_re = { + 'samples': r'(?P\d+)', + '%': r'(?P\S+)', + 'linenr info': r'(?P\(no location information\)|\S+:\d+)', + 'image name': r'(?P\S+(?:\s\(tgid:[^)]*\))?)', + 'app name': r'(?P\S+)', + 'symbol name': r'(?P\(no symbols\)|.+?)', + } + + def __init__(self, infile): + LineParser.__init__(self, infile) + self.entries = {} + self.entry_re = None + + def add_entry(self, callers, function, callees): + try: + entry = self.entries[function.id] + except KeyError: + self.entries[function.id] = (callers, function, callees) + else: + callers_total, function_total, callees_total = entry + self.update_subentries_dict(callers_total, callers) + function_total.samples += function.samples + self.update_subentries_dict(callees_total, callees) + + def update_subentries_dict(self, totals, partials): + for partial in partials.itervalues(): + try: + total = totals[partial.id] + except KeyError: + totals[partial.id] = partial + else: + total.samples += partial.samples + + def parse(self): + # read lookahead + self.readline() + + self.parse_header() + while self.lookahead(): + self.parse_entry() + + profile = Profile() + + reverse_call_samples = {} + + # populate the profile + profile[SAMPLES] = 0 + for _callers, _function, _callees in self.entries.itervalues(): + function = Function(_function.id, _function.name) + function[SAMPLES] = _function.samples + profile.add_function(function) + profile[SAMPLES] += _function.samples + + if _function.application: + function[PROCESS] = os.path.basename(_function.application) + if _function.image: + function[MODULE] = os.path.basename(_function.image) + + total_callee_samples = 0 + for _callee in _callees.itervalues(): + total_callee_samples += _callee.samples + + for _callee in _callees.itervalues(): + if not _callee.self: + call = Call(_callee.id) + call[SAMPLES] = _callee.samples + function.add_call(call) + + # compute derived data + profile.validate() + profile.find_cycles() + profile.ratio(TIME_RATIO, SAMPLES) + profile.call_ratios(SAMPLES) + profile.integrate(TOTAL_TIME_RATIO, TIME_RATIO) + + return profile + + def parse_header(self): + while not self.match_header(): + self.consume() + line = self.lookahead() + fields = re.split(r'\s\s+', line) + entry_re = r'^\s*' + r'\s+'.join([self._fields_re[field] for field in fields]) + r'(?P\s+\[self\])?$' + self.entry_re = re.compile(entry_re) + self.skip_separator() + + def parse_entry(self): + callers = self.parse_subentries() + if self.match_primary(): + function = self.parse_subentry() + if function is not None: + callees = self.parse_subentries() + self.add_entry(callers, function, callees) + self.skip_separator() + + def parse_subentries(self): + subentries = {} + while self.match_secondary(): + subentry = self.parse_subentry() + subentries[subentry.id] = subentry + return subentries + + def parse_subentry(self): + entry = Struct() + line = self.consume() + mo = self.entry_re.match(line) + if not mo: + raise ParseError('failed to parse', line) + fields = mo.groupdict() + entry.samples = int(fields.get('samples', 0)) + entry.percentage = float(fields.get('percentage', 0.0)) + if 'source' in fields and fields['source'] != '(no location information)': + source = fields['source'] + filename, lineno = source.split(':') + entry.filename = filename + entry.lineno = int(lineno) + else: + source = '' + entry.filename = None + entry.lineno = None + entry.image = fields.get('image', '') + entry.application = fields.get('application', '') + if 'symbol' in fields and fields['symbol'] != '(no symbols)': + entry.symbol = fields['symbol'] + else: + entry.symbol = '' + if entry.symbol.startswith('"') and entry.symbol.endswith('"'): + entry.symbol = entry.symbol[1:-1] + entry.id = ':'.join((entry.application, entry.image, source, entry.symbol)) + entry.self = fields.get('self', None) != None + if entry.self: + entry.id += ':self' + if entry.symbol: + entry.name = entry.symbol + else: + entry.name = entry.image + return entry + + def skip_separator(self): + while not self.match_separator(): + self.consume() + self.consume() + + def match_header(self): + line = self.lookahead() + return line.startswith('samples') + + def match_separator(self): + line = self.lookahead() + return line == '-'*len(line) + + def match_primary(self): + line = self.lookahead() + return not line[:1].isspace() + + def match_secondary(self): + line = self.lookahead() + return line[:1].isspace() + + +class SharkParser(LineParser): + """Parser for MacOSX Shark output. + + Author: tom@dbservice.com + """ + + def __init__(self, infile): + LineParser.__init__(self, infile) + self.stack = [] + self.entries = {} + + def add_entry(self, function): + try: + entry = self.entries[function.id] + except KeyError: + self.entries[function.id] = (function, { }) + else: + function_total, callees_total = entry + function_total.samples += function.samples + + def add_callee(self, function, callee): + func, callees = self.entries[function.id] + try: + entry = callees[callee.id] + except KeyError: + callees[callee.id] = callee + else: + entry.samples += callee.samples + + def parse(self): + self.readline() + self.readline() + self.readline() + self.readline() + + match = re.compile(r'(?P[|+ ]*)(?P\d+), (?P[^,]+), (?P.*)') + + while self.lookahead(): + line = self.consume() + mo = match.match(line) + if not mo: + raise ParseError('failed to parse', line) + + fields = mo.groupdict() + prefix = len(fields.get('prefix', 0)) / 2 - 1 + + symbol = str(fields.get('symbol', 0)) + image = str(fields.get('image', 0)) + + entry = Struct() + entry.id = ':'.join([symbol, image]) + entry.samples = int(fields.get('samples', 0)) + + entry.name = symbol + entry.image = image + + # adjust the callstack + if prefix < len(self.stack): + del self.stack[prefix:] + + if prefix == len(self.stack): + self.stack.append(entry) + + # if the callstack has had an entry, it's this functions caller + if prefix > 0: + self.add_callee(self.stack[prefix - 1], entry) + + self.add_entry(entry) + + profile = Profile() + profile[SAMPLES] = 0 + for _function, _callees in self.entries.itervalues(): + function = Function(_function.id, _function.name) + function[SAMPLES] = _function.samples + profile.add_function(function) + profile[SAMPLES] += _function.samples + + if _function.image: + function[MODULE] = os.path.basename(_function.image) + + for _callee in _callees.itervalues(): + call = Call(_callee.id) + call[SAMPLES] = _callee.samples + function.add_call(call) + + # compute derived data + profile.validate() + profile.find_cycles() + profile.ratio(TIME_RATIO, SAMPLES) + profile.call_ratios(SAMPLES) + profile.integrate(TOTAL_TIME_RATIO, TIME_RATIO) + + return profile + + +class PstatsParser: + """Parser python profiling statistics saved with te pstats module.""" + + def __init__(self, *filename): + import pstats + self.stats = pstats.Stats(*filename) + self.profile = Profile() + self.function_ids = {} + + def get_function_name(self, (filename, line, name)): + module = os.path.splitext(filename)[0] + module = os.path.basename(module) + return "%s:%d:%s" % (module, line, name) + + def get_function(self, key): + try: + id = self.function_ids[key] + except KeyError: + id = len(self.function_ids) + name = self.get_function_name(key) + function = Function(id, name) + self.profile.functions[id] = function + self.function_ids[key] = id + else: + function = self.profile.functions[id] + return function + + def parse(self): + self.profile[TIME] = 0.0 + self.profile[TOTAL_TIME] = self.stats.total_tt + for fn, (cc, nc, tt, ct, callers) in self.stats.stats.iteritems(): + callee = self.get_function(fn) + callee[CALLS] = nc + callee[TOTAL_TIME] = ct + callee[TIME] = tt + self.profile[TIME] += tt + self.profile[TOTAL_TIME] = max(self.profile[TOTAL_TIME], ct) + for fn, value in callers.iteritems(): + caller = self.get_function(fn) + call = Call(callee.id) + if isinstance(value, tuple): + for i in xrange(0, len(value), 4): + nc, cc, tt, ct = value[i:i+4] + if CALLS in call: + call[CALLS] += cc + else: + call[CALLS] = cc + + if TOTAL_TIME in call: + call[TOTAL_TIME] += ct + else: + call[TOTAL_TIME] = ct + + else: + call[CALLS] = value + call[TOTAL_TIME] = ratio(value, nc)*ct + + caller.add_call(call) + #self.stats.print_stats() + #self.stats.print_callees() + + # Compute derived events + self.profile.validate() + self.profile.ratio(TIME_RATIO, TIME) + self.profile.ratio(TOTAL_TIME_RATIO, TOTAL_TIME) + + return self.profile + + +class Theme: + + def __init__(self, + bgcolor = (0.0, 0.0, 1.0), + mincolor = (0.0, 0.0, 0.0), + maxcolor = (0.0, 0.0, 1.0), + fontname = "Arial", + minfontsize = 10.0, + maxfontsize = 10.0, + minpenwidth = 0.5, + maxpenwidth = 4.0, + gamma = 2.2): + self.bgcolor = bgcolor + self.mincolor = mincolor + self.maxcolor = maxcolor + self.fontname = fontname + self.minfontsize = minfontsize + self.maxfontsize = maxfontsize + self.minpenwidth = minpenwidth + self.maxpenwidth = maxpenwidth + self.gamma = gamma + + def graph_bgcolor(self): + return self.hsl_to_rgb(*self.bgcolor) + + def graph_fontname(self): + return self.fontname + + def graph_fontsize(self): + return self.minfontsize + + def node_bgcolor(self, weight): + return self.color(weight) + + def node_fgcolor(self, weight): + return self.graph_bgcolor() + + def node_fontsize(self, weight): + return self.fontsize(weight) + + def edge_color(self, weight): + return self.color(weight) + + def edge_fontsize(self, weight): + return self.fontsize(weight) + + def edge_penwidth(self, weight): + return max(weight*self.maxpenwidth, self.minpenwidth) + + def edge_arrowsize(self, weight): + return 0.5 * math.sqrt(self.edge_penwidth(weight)) + + def fontsize(self, weight): + return max(weight**2 * self.maxfontsize, self.minfontsize) + + def color(self, weight): + weight = min(max(weight, 0.0), 1.0) + + hmin, smin, lmin = self.mincolor + hmax, smax, lmax = self.maxcolor + + h = hmin + weight*(hmax - hmin) + s = smin + weight*(smax - smin) + l = lmin + weight*(lmax - lmin) + + return self.hsl_to_rgb(h, s, l) + + def hsl_to_rgb(self, h, s, l): + """Convert a color from HSL color-model to RGB. + + See also: + - http://www.w3.org/TR/css3-color/#hsl-color + """ + + h = h % 1.0 + s = min(max(s, 0.0), 1.0) + l = min(max(l, 0.0), 1.0) + + if l <= 0.5: + m2 = l*(s + 1.0) + else: + m2 = l + s - l*s + m1 = l*2.0 - m2 + r = self._hue_to_rgb(m1, m2, h + 1.0/3.0) + g = self._hue_to_rgb(m1, m2, h) + b = self._hue_to_rgb(m1, m2, h - 1.0/3.0) + + # Apply gamma correction + r **= self.gamma + g **= self.gamma + b **= self.gamma + + return (r, g, b) + + def _hue_to_rgb(self, m1, m2, h): + if h < 0.0: + h += 1.0 + elif h > 1.0: + h -= 1.0 + if h*6 < 1.0: + return m1 + (m2 - m1)*h*6.0 + elif h*2 < 1.0: + return m2 + elif h*3 < 2.0: + return m1 + (m2 - m1)*(2.0/3.0 - h)*6.0 + else: + return m1 + + +TEMPERATURE_COLORMAP = Theme( + mincolor = (2.0/3.0, 0.80, 0.25), # dark blue + maxcolor = (0.0, 1.0, 0.5), # satured red + gamma = 1.0 +) + +PINK_COLORMAP = Theme( + mincolor = (0.0, 1.0, 0.90), # pink + maxcolor = (0.0, 1.0, 0.5), # satured red +) + +GRAY_COLORMAP = Theme( + mincolor = (0.0, 0.0, 0.85), # light gray + maxcolor = (0.0, 0.0, 0.0), # black +) + +BW_COLORMAP = Theme( + minfontsize = 8.0, + maxfontsize = 24.0, + mincolor = (0.0, 0.0, 0.0), # black + maxcolor = (0.0, 0.0, 0.0), # black + minpenwidth = 0.1, + maxpenwidth = 8.0, +) + + +class DotWriter: + """Writer for the DOT language. + + See also: + - "The DOT Language" specification + http://www.graphviz.org/doc/info/lang.html + """ + + def __init__(self, fp): + self.fp = fp + + def graph(self, profile, theme): + self.begin_graph() + + fontname = theme.graph_fontname() + + self.attr('graph', fontname=fontname, ranksep=0.25, nodesep=0.125) + self.attr('node', fontname=fontname, shape="box", style="filled,rounded", fontcolor="white", width=0, height=0) + self.attr('edge', fontname=fontname) + + for function in profile.functions.itervalues(): + labels = [] + for event in PROCESS, MODULE: + if event in function.events: + label = event.format(function[event]) + labels.append(label) + labels.append(function.name) + for event in TOTAL_TIME_RATIO, TIME_RATIO, CALLS: + if event in function.events: + label = event.format(function[event]) + labels.append(label) + + try: + weight = function[PRUNE_RATIO] + except UndefinedEvent: + weight = 0.0 + + label = '\n'.join(labels) + self.node(function.id, + label = label, + color = self.color(theme.node_bgcolor(weight)), + fontcolor = self.color(theme.node_fgcolor(weight)), + fontsize = "%.2f" % theme.node_fontsize(weight), + ) + + for call in function.calls.itervalues(): + callee = profile.functions[call.callee_id] + + labels = [] + for event in TOTAL_TIME_RATIO, CALLS: + if event in call.events: + label = event.format(call[event]) + labels.append(label) + + try: + weight = call[PRUNE_RATIO] + except UndefinedEvent: + try: + weight = callee[PRUNE_RATIO] + except UndefinedEvent: + weight = 0.0 + + label = '\n'.join(labels) + + self.edge(function.id, call.callee_id, + label = label, + color = self.color(theme.edge_color(weight)), + fontcolor = self.color(theme.edge_color(weight)), + fontsize = "%.2f" % theme.edge_fontsize(weight), + penwidth = "%.2f" % theme.edge_penwidth(weight), + labeldistance = "%.2f" % theme.edge_penwidth(weight), + arrowsize = "%.2f" % theme.edge_arrowsize(weight), + ) + + self.end_graph() + + def begin_graph(self): + self.write('digraph {\n') + + def end_graph(self): + self.write('}\n') + + def attr(self, what, **attrs): + self.write("\t") + self.write(what) + self.attr_list(attrs) + self.write(";\n") + + def node(self, node, **attrs): + self.write("\t") + self.id(node) + self.attr_list(attrs) + self.write(";\n") + + def edge(self, src, dst, **attrs): + self.write("\t") + self.id(src) + self.write(" -> ") + self.id(dst) + self.attr_list(attrs) + self.write(";\n") + + def attr_list(self, attrs): + if not attrs: + return + self.write(' [') + first = True + for name, value in attrs.iteritems(): + if first: + first = False + else: + self.write(", ") + self.id(name) + self.write('=') + self.id(value) + self.write(']') + + def id(self, id): + if isinstance(id, (int, float)): + s = str(id) + elif isinstance(id, str): + if id.isalnum(): + s = id + else: + s = self.escape(id) + else: + raise TypeError + self.write(s) + + def color(self, (r, g, b)): + + def float2int(f): + if f <= 0.0: + return 0 + if f >= 1.0: + return 255 + return int(255.0*f + 0.5) + + return "#" + "".join(["%02x" % float2int(c) for c in (r, g, b)]) + + def escape(self, s): + s = s.encode('utf-8') + s = s.replace('\\', r'\\') + s = s.replace('\n', r'\n') + s = s.replace('\t', r'\t') + s = s.replace('"', r'\"') + return '"' + s + '"' + + def write(self, s): + self.fp.write(s) + + +class Main: + """Main program.""" + + themes = { + "color": TEMPERATURE_COLORMAP, + "pink": PINK_COLORMAP, + "gray": GRAY_COLORMAP, + "bw": BW_COLORMAP, + } + + def main(self): + """Main program.""" + + parser = optparse.OptionParser( + usage="\n\t%prog [options] [file] ...", + version="%%prog %s" % __version__) + parser.add_option( + '-o', '--output', metavar='FILE', + type="string", dest="output", + help="output filename [stdout]") + parser.add_option( + '-n', '--node-thres', metavar='PERCENTAGE', + type="float", dest="node_thres", default=0.5, + help="eliminate nodes below this threshold [default: %default]") + parser.add_option( + '-e', '--edge-thres', metavar='PERCENTAGE', + type="float", dest="edge_thres", default=0.1, + help="eliminate edges below this threshold [default: %default]") + parser.add_option( + '-f', '--format', + type="choice", choices=('prof', 'oprofile', 'pstats', 'shark'), + dest="format", default="prof", + help="profile format: prof, oprofile, or pstats [default: %default]") + parser.add_option( + '-c', '--colormap', + type="choice", choices=('color', 'pink', 'gray', 'bw'), + dest="theme", default="color", + help="color map: color, pink, gray, or bw [default: %default]") + parser.add_option( + '-s', '--strip', + action="store_true", + dest="strip", default=False, + help="strip function parameters, template parameters, and const modifiers from demangled C++ function names") + parser.add_option( + '-w', '--wrap', + action="store_true", + dest="wrap", default=False, + help="wrap function names") + (self.options, self.args) = parser.parse_args(sys.argv[1:]) + + if len(self.args) > 1 and self.options.format != 'pstats': + parser.error('incorrect number of arguments') + + try: + self.theme = self.themes[self.options.theme] + except KeyError: + parser.error('invalid colormap \'%s\'' % self.options.theme) + + if self.options.format == 'prof': + if not self.args: + fp = sys.stdin + else: + fp = open(self.args[0], 'rt') + parser = GprofParser(fp) + elif self.options.format == 'oprofile': + if not self.args: + fp = sys.stdin + else: + fp = open(self.args[0], 'rt') + parser = OprofileParser(fp) + elif self.options.format == 'pstats': + if not self.args: + parser.error('at least a file must be specified for pstats input') + parser = PstatsParser(*self.args) + elif self.options.format == 'shark': + if not self.args: + fp = sys.stdin + else: + fp = open(self.args[0], 'rt') + parser = SharkParser(fp) + else: + parser.error('invalid format \'%s\'' % self.options.format) + + self.profile = parser.parse() + + if self.options.output is None: + self.output = sys.stdout + else: + self.output = open(self.options.output, 'wt') + + self.write_graph() + + _parenthesis_re = re.compile(r'\([^()]*\)') + _angles_re = re.compile(r'<[^<>]*>') + _const_re = re.compile(r'\s+const$') + + def strip_function_name(self, name): + """Remove extraneous information from C++ demangled function names.""" + + # Strip function parameters from name by recursively removing paired parenthesis + while True: + name, n = self._parenthesis_re.subn('', name) + if not n: + break + + # Strip const qualifier + name = self._const_re.sub('', name) + + # Strip template parameters from name by recursively removing paired angles + while True: + name, n = self._angles_re.subn('', name) + if not n: + break + + return name + + def wrap_function_name(self, name): + """Split the function name on multiple lines.""" + + if len(name) > 32: + ratio = 2.0/3.0 + height = max(int(len(name)/(1.0 - ratio) + 0.5), 1) + width = max(len(name)/height, 32) + # TODO: break lines in symbols + name = textwrap.fill(name, width, break_long_words=False) + + # Take away spaces + name = name.replace(", ", ",") + name = name.replace("> >", ">>") + name = name.replace("> >", ">>") # catch consecutive + + return name + + def compress_function_name(self, name): + """Compress function name according to the user preferences.""" + + if self.options.strip: + name = self.strip_function_name(name) + + if self.options.wrap: + name = self.wrap_function_name(name) + + # TODO: merge functions with same resulting name + + return name + + def write_graph(self): + dot = DotWriter(self.output) + profile = self.profile + profile.prune(self.options.node_thres/100.0, self.options.edge_thres/100.0) + + for function in profile.functions.itervalues(): + function.name = self.compress_function_name(function.name) + + dot.graph(profile, self.theme) + + +if __name__ == '__main__': + Main().main() diff --git a/libs/tvdb_api/tests/runtests.py b/libs/tvdb_api/tests/runtests.py new file mode 100755 index 00000000..ebb73d9c --- /dev/null +++ b/libs/tvdb_api/tests/runtests.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python +#encoding:utf-8 +#author:dbr/Ben +#project:tvdb_api +#repository:http://github.com/dbr/tvdb_api +#license:unlicense (http://unlicense.org/) + +import sys +import unittest + +import test_tvdb_api + +def main(): + suite = unittest.TestSuite([ + unittest.TestLoader().loadTestsFromModule(test_tvdb_api) + ]) + + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + if result.wasSuccessful(): + return 0 + else: + return 1 + +if __name__ == '__main__': + sys.exit( + int(main()) + ) diff --git a/libs/tvdb_api/tests/test_tvdb_api.py b/libs/tvdb_api/tests/test_tvdb_api.py new file mode 100644 index 00000000..0947461e --- /dev/null +++ b/libs/tvdb_api/tests/test_tvdb_api.py @@ -0,0 +1,526 @@ +#!/usr/bin/env python +#encoding:utf-8 +#author:dbr/Ben +#project:tvdb_api +#repository:http://github.com/dbr/tvdb_api +#license:unlicense (http://unlicense.org/) + +"""Unittests for tvdb_api +""" + +import os +import sys +import datetime +import unittest + +# Force parent directory onto path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import tvdb_api +import tvdb_ui +from tvdb_api import (tvdb_shownotfound, tvdb_seasonnotfound, +tvdb_episodenotfound, tvdb_attributenotfound) + +class test_tvdb_basic(unittest.TestCase): + # Used to store the cached instance of Tvdb() + t = None + + def setUp(self): + if self.t is None: + self.__class__.t = tvdb_api.Tvdb(cache = True, banners = False) + + def test_different_case(self): + """Checks the auto-correction of show names is working. + It should correct the weirdly capitalised 'sCruBs' to 'Scrubs' + """ + self.assertEquals(self.t['scrubs'][1][4]['episodename'], 'My Old Lady') + self.assertEquals(self.t['sCruBs']['seriesname'], 'Scrubs') + + def test_spaces(self): + """Checks shownames with spaces + """ + self.assertEquals(self.t['My Name Is Earl']['seriesname'], 'My Name Is Earl') + self.assertEquals(self.t['My Name Is Earl'][1][4]['episodename'], 'Faked His Own Death') + + def test_numeric(self): + """Checks numeric show names + """ + self.assertEquals(self.t['24'][2][20]['episodename'], 'Day 2: 3:00 A.M.-4:00 A.M.') + self.assertEquals(self.t['24']['seriesname'], '24') + + def test_show_iter(self): + """Iterating over a show returns each seasons + """ + self.assertEquals( + len( + [season for season in self.t['Life on Mars']] + ), + 2 + ) + + def test_season_iter(self): + """Iterating over a show returns episodes + """ + self.assertEquals( + len( + [episode for episode in self.t['Life on Mars'][1]] + ), + 8 + ) + + def test_get_episode_overview(self): + """Checks episode overview is retrieved correctly. + """ + self.assertEquals( + self.t['Battlestar Galactica (2003)'][1][6]['overview'].startswith( + 'When a new copy of Doral, a Cylon who had been previously'), + True + ) + + def test_get_parent(self): + """Check accessing series from episode instance + """ + show = self.t['Battlestar Galactica (2003)'] + season = show[1] + episode = show[1][1] + + self.assertEquals( + season.show, + show + ) + + self.assertEquals( + episode.season, + season + ) + + self.assertEquals( + episode.season.show, + show + ) + + +class test_tvdb_errors(unittest.TestCase): + # Used to store the cached instance of Tvdb() + t = None + + def setUp(self): + if self.t is None: + self.__class__.t = tvdb_api.Tvdb(cache = True, banners = False) + + def test_seasonnotfound(self): + """Checks exception is thrown when season doesn't exist. + """ + self.assertRaises(tvdb_seasonnotfound, lambda:self.t['CNNNN'][10][1]) + + def test_shownotfound(self): + """Checks exception is thrown when episode doesn't exist. + """ + self.assertRaises(tvdb_shownotfound, lambda:self.t['the fake show thingy']) + + def test_episodenotfound(self): + """Checks exception is raised for non-existent episode + """ + self.assertRaises(tvdb_episodenotfound, lambda:self.t['Scrubs'][1][30]) + + def test_attributenamenotfound(self): + """Checks exception is thrown for if an attribute isn't found. + """ + self.assertRaises(tvdb_attributenotfound, lambda:self.t['CNNNN'][1][6]['afakeattributething']) + self.assertRaises(tvdb_attributenotfound, lambda:self.t['CNNNN']['afakeattributething']) + +class test_tvdb_search(unittest.TestCase): + # Used to store the cached instance of Tvdb() + t = None + + def setUp(self): + if self.t is None: + self.__class__.t = tvdb_api.Tvdb(cache = True, banners = False) + + def test_search_len(self): + """There should be only one result matching + """ + self.assertEquals(len(self.t['My Name Is Earl'].search('Faked His Own Death')), 1) + + def test_search_checkname(self): + """Checks you can get the episode name of a search result + """ + self.assertEquals(self.t['Scrubs'].search('my first')[0]['episodename'], 'My First Day') + self.assertEquals(self.t['My Name Is Earl'].search('Faked His Own Death')[0]['episodename'], 'Faked His Own Death') + + def test_search_multiresults(self): + """Checks search can return multiple results + """ + self.assertEquals(len(self.t['Scrubs'].search('my first')) >= 3, True) + + def test_search_no_params_error(self): + """Checks not supplying search info raises TypeError""" + self.assertRaises( + TypeError, + lambda: self.t['Scrubs'].search() + ) + + def test_search_season(self): + """Checks the searching of a single season""" + self.assertEquals( + len(self.t['Scrubs'][1].search("First")), + 3 + ) + + def test_search_show(self): + """Checks the searching of an entire show""" + self.assertEquals( + len(self.t['CNNNN'].search('CNNNN', key='episodename')), + 3 + ) + + def test_aired_on(self): + """Tests airedOn show method""" + sr = self.t['Scrubs'].airedOn(datetime.date(2001, 10, 2)) + self.assertEquals(len(sr), 1) + self.assertEquals(sr[0]['episodename'], u'My First Day') + +class test_tvdb_data(unittest.TestCase): + # Used to store the cached instance of Tvdb() + t = None + + def setUp(self): + if self.t is None: + self.__class__.t = tvdb_api.Tvdb(cache = True, banners = False) + + def test_episode_data(self): + """Check the firstaired value is retrieved + """ + self.assertEquals( + self.t['lost']['firstaired'], + '2004-09-22' + ) + +class test_tvdb_misc(unittest.TestCase): + # Used to store the cached instance of Tvdb() + t = None + + def setUp(self): + if self.t is None: + self.__class__.t = tvdb_api.Tvdb(cache = True, banners = False) + + def test_repr_show(self): + """Check repr() of Season + """ + self.assertEquals( + repr(self.t['CNNNN']), + "" + ) + def test_repr_season(self): + """Check repr() of Season + """ + self.assertEquals( + repr(self.t['CNNNN'][1]), + "" + ) + def test_repr_episode(self): + """Check repr() of Episode + """ + self.assertEquals( + repr(self.t['CNNNN'][1][1]), + "" + ) + def test_have_all_languages(self): + """Check valid_languages is up-to-date (compared to languages.xml) + """ + et = self.t._getetsrc( + "http://thetvdb.com/api/%s/languages.xml" % ( + self.t.config['apikey'] + ) + ) + languages = [x.find("abbreviation").text for x in et.findall("Language")] + + self.assertEquals( + sorted(languages), + sorted(self.t.config['valid_languages']) + ) + +class test_tvdb_languages(unittest.TestCase): + def test_episode_name_french(self): + """Check episode data is in French (language="fr") + """ + t = tvdb_api.Tvdb(cache = True, language = "fr") + self.assertEquals( + t['scrubs'][1][1]['episodename'], + "Mon premier jour" + ) + self.assertTrue( + t['scrubs']['overview'].startswith( + u"J.D. est un jeune m\xe9decin qui d\xe9bute" + ) + ) + + def test_episode_name_spanish(self): + """Check episode data is in Spanish (language="es") + """ + t = tvdb_api.Tvdb(cache = True, language = "es") + self.assertEquals( + t['scrubs'][1][1]['episodename'], + "Mi Primer Dia" + ) + self.assertTrue( + t['scrubs']['overview'].startswith( + u'Scrubs es una divertida comedia' + ) + ) + + def test_multilanguage_selection(self): + """Check selected language is used + """ + class SelectEnglishUI(tvdb_ui.BaseUI): + def selectSeries(self, allSeries): + return [x for x in allSeries if x['language'] == "en"][0] + + class SelectItalianUI(tvdb_ui.BaseUI): + def selectSeries(self, allSeries): + return [x for x in allSeries if x['language'] == "it"][0] + + t_en = tvdb_api.Tvdb( + cache=True, + custom_ui = SelectEnglishUI, + language = "en") + t_it = tvdb_api.Tvdb( + cache=True, + custom_ui = SelectItalianUI, + language = "it") + + self.assertEquals( + t_en['dexter'][1][2]['episodename'], "Crocodile" + ) + self.assertEquals( + t_it['dexter'][1][2]['episodename'], "Lacrime di coccodrillo" + ) + + +class test_tvdb_unicode(unittest.TestCase): + def test_search_in_chinese(self): + """Check searching for show with language=zh returns Chinese seriesname + """ + t = tvdb_api.Tvdb(cache = True, language = "zh") + show = t[u'T\xecnh Ng\u01b0\u1eddi Hi\u1ec7n \u0110\u1ea1i'] + self.assertEquals( + type(show), + tvdb_api.Show + ) + + self.assertEquals( + show['seriesname'], + u'T\xecnh Ng\u01b0\u1eddi Hi\u1ec7n \u0110\u1ea1i' + ) + + def test_search_in_all_languages(self): + """Check search_all_languages returns Chinese show, with language=en + """ + t = tvdb_api.Tvdb(cache = True, search_all_languages = True, language="en") + show = t[u'T\xecnh Ng\u01b0\u1eddi Hi\u1ec7n \u0110\u1ea1i'] + self.assertEquals( + type(show), + tvdb_api.Show + ) + + self.assertEquals( + show['seriesname'], + u'Virtues Of Harmony II' + ) + +class test_tvdb_banners(unittest.TestCase): + # Used to store the cached instance of Tvdb() + t = None + + def setUp(self): + if self.t is None: + self.__class__.t = tvdb_api.Tvdb(cache = True, banners = True) + + def test_have_banners(self): + """Check banners at least one banner is found + """ + self.assertEquals( + len(self.t['scrubs']['_banners']) > 0, + True + ) + + def test_banner_url(self): + """Checks banner URLs start with http:// + """ + for banner_type, banner_data in self.t['scrubs']['_banners'].items(): + for res, res_data in banner_data.items(): + for bid, banner_info in res_data.items(): + self.assertEquals( + banner_info['_bannerpath'].startswith("http://"), + True + ) + + def test_episode_image(self): + """Checks episode 'filename' image is fully qualified URL + """ + self.assertEquals( + self.t['scrubs'][1][1]['filename'].startswith("http://"), + True + ) + + def test_show_artwork(self): + """Checks various image URLs within season data are fully qualified + """ + for key in ['banner', 'fanart', 'poster']: + self.assertEquals( + self.t['scrubs'][key].startswith("http://"), + True + ) + +class test_tvdb_actors(unittest.TestCase): + t = None + def setUp(self): + if self.t is None: + self.__class__.t = tvdb_api.Tvdb(cache = True, actors = True) + + def test_actors_is_correct_datatype(self): + """Check show/_actors key exists and is correct type""" + self.assertTrue( + isinstance( + self.t['scrubs']['_actors'], + tvdb_api.Actors + ) + ) + + def test_actors_has_actor(self): + """Check show has at least one Actor + """ + self.assertTrue( + isinstance( + self.t['scrubs']['_actors'][0], + tvdb_api.Actor + ) + ) + + def test_actor_has_name(self): + """Check first actor has a name""" + self.assertEquals( + self.t['scrubs']['_actors'][0]['name'], + "Zach Braff" + ) + + def test_actor_image_corrected(self): + """Check image URL is fully qualified + """ + for actor in self.t['scrubs']['_actors']: + if actor['image'] is not None: + # Actor's image can be None, it displays as the placeholder + # image on thetvdb.com + self.assertTrue( + actor['image'].startswith("http://") + ) + +class test_tvdb_doctest(unittest.TestCase): + # Used to store the cached instance of Tvdb() + t = None + + def setUp(self): + if self.t is None: + self.__class__.t = tvdb_api.Tvdb(cache = True, banners = False) + + def test_doctest(self): + """Check docstring examples works""" + import doctest + doctest.testmod(tvdb_api) + + +class test_tvdb_custom_caching(unittest.TestCase): + def test_true_false_string(self): + """Tests setting cache to True/False/string + + Basic tests, only checking for errors + """ + + tvdb_api.Tvdb(cache = True) + tvdb_api.Tvdb(cache = False) + tvdb_api.Tvdb(cache = "/tmp") + + def test_invalid_cache_option(self): + """Tests setting cache to invalid value + """ + + try: + tvdb_api.Tvdb(cache = 2.3) + except ValueError: + pass + else: + self.fail("Expected ValueError from setting cache to float") + + def test_custom_urlopener(self): + class UsedCustomOpener(Exception): + pass + + import urllib2 + class TestOpener(urllib2.BaseHandler): + def default_open(self, request): + print request.get_method() + raise UsedCustomOpener("Something") + + custom_opener = urllib2.build_opener(TestOpener()) + t = tvdb_api.Tvdb(cache = custom_opener) + try: + t['scrubs'] + except UsedCustomOpener: + pass + else: + self.fail("Did not use custom opener") + +class test_tvdb_by_id(unittest.TestCase): + t = None + def setUp(self): + if self.t is None: + self.__class__.t = tvdb_api.Tvdb(cache = True, actors = True) + + def test_actors_is_correct_datatype(self): + """Check show/_actors key exists and is correct type""" + self.assertEquals( + self.t[76156]['seriesname'], + 'Scrubs' + ) + + +class test_tvdb_zip(unittest.TestCase): + # Used to store the cached instance of Tvdb() + t = None + + def setUp(self): + if self.t is None: + self.__class__.t = tvdb_api.Tvdb(cache = True, useZip = True) + + def test_get_series_from_zip(self): + """ + """ + self.assertEquals(self.t['scrubs'][1][4]['episodename'], 'My Old Lady') + self.assertEquals(self.t['sCruBs']['seriesname'], 'Scrubs') + + def test_spaces_from_zip(self): + """Checks shownames with spaces + """ + self.assertEquals(self.t['My Name Is Earl']['seriesname'], 'My Name Is Earl') + self.assertEquals(self.t['My Name Is Earl'][1][4]['episodename'], 'Faked His Own Death') + + +class test_tvdb_show_search(unittest.TestCase): + # Used to store the cached instance of Tvdb() + t = None + + def setUp(self): + if self.t is None: + self.__class__.t = tvdb_api.Tvdb(cache = True, useZip = True) + + def test_search(self): + """Test Tvdb.search method + """ + results = self.t.search("my name is earl") + all_ids = [x['seriesid'] for x in results] + self.assertTrue('75397' in all_ids) + + +if __name__ == '__main__': + runner = unittest.TextTestRunner(verbosity = 2) + unittest.main(testRunner = runner) diff --git a/libs/tvdb_api/tvdb_api.py b/libs/tvdb_api/tvdb_api.py new file mode 100644 index 00000000..4bfe78a2 --- /dev/null +++ b/libs/tvdb_api/tvdb_api.py @@ -0,0 +1,874 @@ +#!/usr/bin/env python +#encoding:utf-8 +#author:dbr/Ben +#project:tvdb_api +#repository:http://github.com/dbr/tvdb_api +#license:unlicense (http://unlicense.org/) + +"""Simple-to-use Python interface to The TVDB's API (thetvdb.com) + +Example usage: + +>>> from tvdb_api import Tvdb +>>> t = Tvdb() +>>> t['Lost'][4][11]['episodename'] +u'Cabin Fever' +""" +__author__ = "dbr/Ben" +__version__ = "1.8.2" + +import os +import time +import urllib +import urllib2 +import getpass +import StringIO +import tempfile +import warnings +import logging +import datetime +import zipfile + +try: + import xml.etree.cElementTree as ElementTree +except ImportError: + import xml.etree.ElementTree as ElementTree + +try: + import gzip +except ImportError: + gzip = None + + +from tvdb_cache import CacheHandler + +from tvdb_ui import BaseUI, ConsoleUI +from tvdb_exceptions import (tvdb_error, tvdb_userabort, tvdb_shownotfound, + tvdb_seasonnotfound, tvdb_episodenotfound, tvdb_attributenotfound) + +lastTimeout = None + +def log(): + return logging.getLogger("tvdb_api") + + +class ShowContainer(dict): + """Simple dict that holds a series of Show instances + """ + + def __init__(self): + self._stack = [] + self._lastgc = time.time() + + def __setitem__(self, key, value): + self._stack.append(key) + + #keep only the 100th latest results + if time.time() - self._lastgc > 20: + tbd = self._stack[:-100] + i = 0 + for o in tbd: + del self[o] + del self._stack[i] + i += 1 + + _lastgc = time.time() + del tbd + + super(ShowContainer, self).__setitem__(key, value) + + +class Show(dict): + """Holds a dict of seasons, and show data. + """ + def __init__(self): + dict.__init__(self) + self.data = {} + + def __repr__(self): + return "" % ( + self.data.get(u'seriesname', 'instance'), + len(self) + ) + + def __getitem__(self, key): + if key in self: + # Key is an episode, return it + return dict.__getitem__(self, key) + + if key in self.data: + # Non-numeric request is for show-data + return dict.__getitem__(self.data, key) + + # Data wasn't found, raise appropriate error + if isinstance(key, int) or key.isdigit(): + # Episode number x was not found + raise tvdb_seasonnotfound("Could not find season %s" % (repr(key))) + else: + # If it's not numeric, it must be an attribute name, which + # doesn't exist, so attribute error. + raise tvdb_attributenotfound("Cannot find attribute %s" % (repr(key))) + + def airedOn(self, date): + ret = self.search(str(date), 'firstaired') + if len(ret) == 0: + raise tvdb_episodenotfound("Could not find any episodes that aired on %s" % date) + return ret + + def search(self, term = None, key = None): + """ + Search all episodes in show. Can search all data, or a specific key (for + example, episodename) + + Always returns an array (can be empty). First index contains the first + match, and so on. + + Each array index is an Episode() instance, so doing + search_results[0]['episodename'] will retrieve the episode name of the + first match. + + Search terms are converted to lower case (unicode) strings. + + # Examples + + These examples assume t is an instance of Tvdb(): + + >>> t = Tvdb() + >>> + + To search for all episodes of Scrubs with a bit of data + containing "my first day": + + >>> t['Scrubs'].search("my first day") + [] + >>> + + Search for "My Name Is Earl" episode named "Faked His Own Death": + + >>> t['My Name Is Earl'].search('Faked His Own Death', key = 'episodename') + [] + >>> + + To search Scrubs for all episodes with "mentor" in the episode name: + + >>> t['scrubs'].search('mentor', key = 'episodename') + [, ] + >>> + + # Using search results + + >>> results = t['Scrubs'].search("my first") + >>> print results[0]['episodename'] + My First Day + >>> for x in results: print x['episodename'] + My First Day + My First Step + My First Kill + >>> + """ + results = [] + for cur_season in self.values(): + searchresult = cur_season.search(term = term, key = key) + if len(searchresult) != 0: + results.extend(searchresult) + + return results + + +class Season(dict): + def __init__(self, show = None): + """The show attribute points to the parent show + """ + self.show = show + + def __repr__(self): + return "" % ( + len(self.keys()) + ) + + def __getitem__(self, episode_number): + if episode_number not in self: + raise tvdb_episodenotfound("Could not find episode %s" % (repr(episode_number))) + else: + return dict.__getitem__(self, episode_number) + + def search(self, term = None, key = None): + """Search all episodes in season, returns a list of matching Episode + instances. + + >>> t = Tvdb() + >>> t['scrubs'][1].search('first day') + [] + >>> + + See Show.search documentation for further information on search + """ + results = [] + for ep in self.values(): + searchresult = ep.search(term = term, key = key) + if searchresult is not None: + results.append( + searchresult + ) + return results + + +class Episode(dict): + def __init__(self, season = None): + """The season attribute points to the parent season + """ + self.season = season + + def __repr__(self): + seasno = int(self.get(u'seasonnumber', 0)) + epno = int(self.get(u'episodenumber', 0)) + epname = self.get(u'episodename') + if epname is not None: + return "" % (seasno, epno, epname) + else: + return "" % (seasno, epno) + + def __getitem__(self, key): + try: + return dict.__getitem__(self, key) + except KeyError: + raise tvdb_attributenotfound("Cannot find attribute %s" % (repr(key))) + + def search(self, term = None, key = None): + """Search episode data for term, if it matches, return the Episode (self). + The key parameter can be used to limit the search to a specific element, + for example, episodename. + + This primarily for use use by Show.search and Season.search. See + Show.search for further information on search + + Simple example: + + >>> e = Episode() + >>> e['episodename'] = "An Example" + >>> e.search("examp") + + >>> + + Limiting by key: + + >>> e.search("examp", key = "episodename") + + >>> + """ + if term == None: + raise TypeError("must supply string to search for (contents)") + + term = unicode(term).lower() + for cur_key, cur_value in self.items(): + cur_key, cur_value = unicode(cur_key).lower(), unicode(cur_value).lower() + if key is not None and cur_key != key: + # Do not search this key + continue + if cur_value.find( unicode(term).lower() ) > -1: + return self + + +class Actors(list): + """Holds all Actor instances for a show + """ + pass + + +class Actor(dict): + """Represents a single actor. Should contain.. + + id, + image, + name, + role, + sortorder + """ + def __repr__(self): + return "" % (self.get("name")) + + +class Tvdb: + """Create easy-to-use interface to name of season/episode name + >>> t = Tvdb() + >>> t['Scrubs'][1][24]['episodename'] + u'My Last Day' + """ + def __init__(self, + interactive = False, + select_first = False, + debug = False, + cache = True, + banners = False, + actors = False, + custom_ui = None, + language = None, + search_all_languages = False, + apikey = None, + forceConnect=False, + useZip=False): + + """interactive (True/False): + When True, uses built-in console UI is used to select the correct show. + When False, the first search result is used. + + select_first (True/False): + Automatically selects the first series search result (rather + than showing the user a list of more than one series). + Is overridden by interactive = False, or specifying a custom_ui + + debug (True/False) DEPRECATED: + Replaced with proper use of logging module. To show debug messages: + + >>> import logging + >>> logging.basicConfig(level = logging.DEBUG) + + cache (True/False/str/unicode/urllib2 opener): + Retrieved XML are persisted to to disc. If true, stores in + tvdb_api folder under your systems TEMP_DIR, if set to + str/unicode instance it will use this as the cache + location. If False, disables caching. Can also be passed + an arbitrary Python object, which is used as a urllib2 + opener, which should be created by urllib2.build_opener + + banners (True/False): + Retrieves the banners for a show. These are accessed + via the _banners key of a Show(), for example: + + >>> Tvdb(banners=True)['scrubs']['_banners'].keys() + ['fanart', 'poster', 'series', 'season'] + + actors (True/False): + Retrieves a list of the actors for a show. These are accessed + via the _actors key of a Show(), for example: + + >>> t = Tvdb(actors=True) + >>> t['scrubs']['_actors'][0]['name'] + u'Zach Braff' + + custom_ui (tvdb_ui.BaseUI subclass): + A callable subclass of tvdb_ui.BaseUI (overrides interactive option) + + language (2 character language abbreviation): + The language of the returned data. Is also the language search + uses. Default is "en" (English). For full list, run.. + + >>> Tvdb().config['valid_languages'] #doctest: +ELLIPSIS + ['da', 'fi', 'nl', ...] + + search_all_languages (True/False): + By default, Tvdb will only search in the language specified using + the language option. When this is True, it will search for the + show in and language + + apikey (str/unicode): + Override the default thetvdb.com API key. By default it will use + tvdb_api's own key (fine for small scripts), but you can use your + own key if desired - this is recommended if you are embedding + tvdb_api in a larger application) + See http://thetvdb.com/?tab=apiregister to get your own key + + forceConnect (bool): + If true it will always try to connect to theTVDB.com even if we + recently timed out. By default it will wait one minute before + trying again, and any requests within that one minute window will + return an exception immediately. + + useZip (bool): + Download the zip archive where possibale, instead of the xml. + This is only used when all episodes are pulled. + And only the main language xml is used, the actor and banner xml are lost. + """ + + global lastTimeout + + # if we're given a lastTimeout that is less than 1 min just give up + if not forceConnect and lastTimeout != None and datetime.datetime.now() - lastTimeout < datetime.timedelta(minutes=1): + raise tvdb_error("We recently timed out, so giving up early this time") + + self.shows = ShowContainer() # Holds all Show classes + self.corrections = {} # Holds show-name to show_id mapping + + self.config = {} + + if apikey is not None: + self.config['apikey'] = apikey + else: + self.config['apikey'] = "0629B785CE550C8D" # tvdb_api's API key + + self.config['debug_enabled'] = debug # show debugging messages + + self.config['custom_ui'] = custom_ui + + self.config['interactive'] = interactive # prompt for correct series? + + self.config['select_first'] = select_first + + self.config['search_all_languages'] = search_all_languages + + self.config['useZip'] = useZip + + + if cache is True: + self.config['cache_enabled'] = True + self.config['cache_location'] = self._getTempDir() + self.urlopener = urllib2.build_opener( + CacheHandler(self.config['cache_location']) + ) + + elif cache is False: + self.config['cache_enabled'] = False + self.urlopener = urllib2.build_opener() # default opener with no caching + + elif isinstance(cache, basestring): + self.config['cache_enabled'] = True + self.config['cache_location'] = cache + self.urlopener = urllib2.build_opener( + CacheHandler(self.config['cache_location']) + ) + + elif isinstance(cache, urllib2.OpenerDirector): + # If passed something from urllib2.build_opener, use that + log().debug("Using %r as urlopener" % cache) + self.config['cache_enabled'] = True + self.urlopener = cache + + else: + raise ValueError("Invalid value for Cache %r (type was %s)" % (cache, type(cache))) + + self.config['banners_enabled'] = banners + self.config['actors_enabled'] = actors + + if self.config['debug_enabled']: + warnings.warn("The debug argument to tvdb_api.__init__ will be removed in the next version. " + "To enable debug messages, use the following code before importing: " + "import logging; logging.basicConfig(level=logging.DEBUG)") + logging.basicConfig(level=logging.DEBUG) + + + # List of language from http://thetvdb.com/api/0629B785CE550C8D/languages.xml + # Hard-coded here as it is realtively static, and saves another HTTP request, as + # recommended on http://thetvdb.com/wiki/index.php/API:languages.xml + self.config['valid_languages'] = [ + "da", "fi", "nl", "de", "it", "es", "fr","pl", "hu","el","tr", + "ru","he","ja","pt","zh","cs","sl", "hr","ko","en","sv","no" + ] + + # thetvdb.com should be based around numeric language codes, + # but to link to a series like http://thetvdb.com/?tab=series&id=79349&lid=16 + # requires the language ID, thus this mapping is required (mainly + # for usage in tvdb_ui - internally tvdb_api will use the language abbreviations) + self.config['langabbv_to_id'] = {'el': 20, 'en': 7, 'zh': 27, + 'it': 15, 'cs': 28, 'es': 16, 'ru': 22, 'nl': 13, 'pt': 26, 'no': 9, + 'tr': 21, 'pl': 18, 'fr': 17, 'hr': 31, 'de': 14, 'da': 10, 'fi': 11, + 'hu': 19, 'ja': 25, 'he': 24, 'ko': 32, 'sv': 8, 'sl': 30} + + if language is None: + self.config['language'] = 'en' + else: + if language not in self.config['valid_languages']: + raise ValueError("Invalid language %s, options are: %s" % ( + language, self.config['valid_languages'] + )) + else: + self.config['language'] = language + + # The following url_ configs are based of the + # http://thetvdb.com/wiki/index.php/Programmers_API + self.config['base_url'] = "http://thetvdb.com" + + if self.config['search_all_languages']: + self.config['url_getSeries'] = u"%(base_url)s/api/GetSeries.php?seriesname=%%s&language=all" % self.config + else: + self.config['url_getSeries'] = u"%(base_url)s/api/GetSeries.php?seriesname=%%s&language=%(language)s" % self.config + + self.config['url_epInfo'] = u"%(base_url)s/api/%(apikey)s/series/%%s/all/%%s.xml" % self.config + self.config['url_epInfo_zip'] = u"%(base_url)s/api/%(apikey)s/series/%%s/all/%%s.zip" % self.config + + self.config['url_seriesInfo'] = u"%(base_url)s/api/%(apikey)s/series/%%s/%%s.xml" % self.config + self.config['url_actorsInfo'] = u"%(base_url)s/api/%(apikey)s/series/%%s/actors.xml" % self.config + + self.config['url_seriesBanner'] = u"%(base_url)s/api/%(apikey)s/series/%%s/banners.xml" % self.config + self.config['url_artworkPrefix'] = u"%(base_url)s/banners/%%s" % self.config + + def _getTempDir(self): + """Returns the [system temp dir]/tvdb_api-u501 (or + tvdb_api-myuser) + """ + if hasattr(os, 'getuid'): + uid = "u%d" % (os.getuid()) + else: + # For Windows + try: + uid = getpass.getuser() + except ImportError: + return os.path.join(tempfile.gettempdir(), "tvdb_api") + + return os.path.join(tempfile.gettempdir(), "tvdb_api-%s" % (uid)) + + def _loadUrl(self, url, recache = False, language=None): + global lastTimeout + try: + log().debug("Retrieving URL %s" % url) + resp = self.urlopener.open(url) + if 'x-local-cache' in resp.headers: + log().debug("URL %s was cached in %s" % ( + url, + resp.headers['x-local-cache']) + ) + if recache: + log().debug("Attempting to recache %s" % url) + resp.recache() + except (IOError, urllib2.URLError), errormsg: + if not str(errormsg).startswith('HTTP Error'): + lastTimeout = datetime.datetime.now() + raise tvdb_error("Could not connect to server: %s" % (errormsg)) + + + # handle gzipped content, + # http://dbr.lighthouseapp.com/projects/13342/tickets/72-gzipped-data-patch + if 'gzip' in resp.headers.get("Content-Encoding", ''): + if gzip: + stream = StringIO.StringIO(resp.read()) + gz = gzip.GzipFile(fileobj=stream) + return gz.read() + + raise tvdb_error("Received gzip data from thetvdb.com, but could not correctly handle it") + + if 'application/zip' in resp.headers.get("Content-Type", ''): + try: + # TODO: The zip contains actors.xml and banners.xml, which are currently ignored [GH-20] + log().debug("We recived a zip file unpacking now ...") + zipdata = StringIO.StringIO() + zipdata.write(resp.read()) + myzipfile = zipfile.ZipFile(zipdata) + return myzipfile.read('%s.xml' % language) + except zipfile.BadZipfile: + if 'x-local-cache' in resp.headers: + resp.delete_cache() + raise tvdb_error("Bad zip file received from thetvdb.com, could not read it") + + return resp.read() + + def _getetsrc(self, url, language=None): + """Loads a URL using caching, returns an ElementTree of the source + """ + src = self._loadUrl(url, language=language) + try: + # TVDB doesn't sanitize \r (CR) from user input in some fields, + # remove it to avoid errors. Change from SickBeard, from will14m + return ElementTree.fromstring(src.rstrip("\r")) + except SyntaxError: + src = self._loadUrl(url, recache=True, language=language) + try: + return ElementTree.fromstring(src.rstrip("\r")) + except SyntaxError, exceptionmsg: + errormsg = "There was an error with the XML retrieved from thetvdb.com:\n%s" % ( + exceptionmsg + ) + + if self.config['cache_enabled']: + errormsg += "\nFirst try emptying the cache folder at..\n%s" % ( + self.config['cache_location'] + ) + + errormsg += "\nIf this does not resolve the issue, please try again later. If the error persists, report a bug on" + errormsg += "\nhttp://dbr.lighthouseapp.com/projects/13342-tvdb_api/overview\n" + raise tvdb_error(errormsg) + + def _setItem(self, sid, seas, ep, attrib, value): + """Creates a new episode, creating Show(), Season() and + Episode()s as required. Called by _getShowData to populate show + + Since the nice-to-use tvdb[1][24]['name] interface + makes it impossible to do tvdb[1][24]['name] = "name" + and still be capable of checking if an episode exists + so we can raise tvdb_shownotfound, we have a slightly + less pretty method of setting items.. but since the API + is supposed to be read-only, this is the best way to + do it! + The problem is that calling tvdb[1][24]['episodename'] = "name" + calls __getitem__ on tvdb[1], there is no way to check if + tvdb.__dict__ should have a key "1" before we auto-create it + """ + if sid not in self.shows: + self.shows[sid] = Show() + if seas not in self.shows[sid]: + self.shows[sid][seas] = Season(show = self.shows[sid]) + if ep not in self.shows[sid][seas]: + self.shows[sid][seas][ep] = Episode(season = self.shows[sid][seas]) + self.shows[sid][seas][ep][attrib] = value + + def _setShowData(self, sid, key, value): + """Sets self.shows[sid] to a new Show instance, or sets the data + """ + if sid not in self.shows: + self.shows[sid] = Show() + self.shows[sid].data[key] = value + + def _cleanData(self, data): + """Cleans up strings returned by TheTVDB.com + + Issues corrected: + - Replaces & with & + - Trailing whitespace + """ + data = data.replace(u"&", u"&") + data = data.strip() + return data + + def search(self, series): + """This searches TheTVDB.com for the series name + and returns the result list + """ + series = urllib.quote(series.encode("utf-8")) + log().debug("Searching for show %s" % series) + seriesEt = self._getetsrc(self.config['url_getSeries'] % (series)) + allSeries = [] + for series in seriesEt: + result = dict((k.tag.lower(), k.text) for k in series.getchildren()) + result['id'] = int(result['id']) + result['lid'] = self.config['langabbv_to_id'][result['language']] + log().debug('Found series %(seriesname)s' % result) + allSeries.append(result) + + return allSeries + + def _getSeries(self, series): + """This searches TheTVDB.com for the series name, + If a custom_ui UI is configured, it uses this to select the correct + series. If not, and interactive == True, ConsoleUI is used, if not + BaseUI is used to select the first result. + """ + allSeries = self.search(series) + + if len(allSeries) == 0: + log().debug('Series result returned zero') + raise tvdb_shownotfound("Show-name search returned zero results (cannot find show on TVDB)") + + if self.config['custom_ui'] is not None: + log().debug("Using custom UI %s" % (repr(self.config['custom_ui']))) + ui = self.config['custom_ui'](config = self.config) + else: + if not self.config['interactive']: + log().debug('Auto-selecting first search result using BaseUI') + ui = BaseUI(config = self.config) + else: + log().debug('Interactively selecting show using ConsoleUI') + ui = ConsoleUI(config = self.config) + + return ui.selectSeries(allSeries) + + def _parseBanners(self, sid): + """Parses banners XML, from + http://thetvdb.com/api/[APIKEY]/series/[SERIES ID]/banners.xml + + Banners are retrieved using t['show name]['_banners'], for example: + + >>> t = Tvdb(banners = True) + >>> t['scrubs']['_banners'].keys() + ['fanart', 'poster', 'series', 'season'] + >>> t['scrubs']['_banners']['poster']['680x1000']['35308']['_bannerpath'] + u'http://thetvdb.com/banners/posters/76156-2.jpg' + >>> + + Any key starting with an underscore has been processed (not the raw + data from the XML) + + This interface will be improved in future versions. + """ + log().debug('Getting season banners for %s' % (sid)) + bannersEt = self._getetsrc( self.config['url_seriesBanner'] % (sid) ) + banners = {} + for cur_banner in bannersEt.findall('Banner'): + bid = cur_banner.find('id').text + btype = cur_banner.find('BannerType') + btype2 = cur_banner.find('BannerType2') + if btype is None or btype2 is None: + continue + btype, btype2 = btype.text, btype2.text + if not btype in banners: + banners[btype] = {} + if not btype2 in banners[btype]: + banners[btype][btype2] = {} + if not bid in banners[btype][btype2]: + banners[btype][btype2][bid] = {} + + for cur_element in cur_banner.getchildren(): + tag = cur_element.tag.lower() + value = cur_element.text + if tag is None or value is None: + continue + tag, value = tag.lower(), value.lower() + banners[btype][btype2][bid][tag] = value + + for k, v in banners[btype][btype2][bid].items(): + if k.endswith("path"): + new_key = "_%s" % (k) + log().debug("Transforming %s to %s" % (k, new_key)) + new_url = self.config['url_artworkPrefix'] % (v) + banners[btype][btype2][bid][new_key] = new_url + + self._setShowData(sid, "_banners", banners) + + def _parseActors(self, sid): + """Parsers actors XML, from + http://thetvdb.com/api/[APIKEY]/series/[SERIES ID]/actors.xml + + Actors are retrieved using t['show name]['_actors'], for example: + + >>> t = Tvdb(actors = True) + >>> actors = t['scrubs']['_actors'] + >>> type(actors) + + >>> type(actors[0]) + + >>> actors[0] + + >>> sorted(actors[0].keys()) + ['id', 'image', 'name', 'role', 'sortorder'] + >>> actors[0]['name'] + u'Zach Braff' + >>> actors[0]['image'] + u'http://thetvdb.com/banners/actors/43640.jpg' + + Any key starting with an underscore has been processed (not the raw + data from the XML) + """ + log().debug("Getting actors for %s" % (sid)) + actorsEt = self._getetsrc(self.config['url_actorsInfo'] % (sid)) + + cur_actors = Actors() + for curActorItem in actorsEt.findall("Actor"): + curActor = Actor() + for curInfo in curActorItem: + tag = curInfo.tag.lower() + value = curInfo.text + if value is not None: + if tag == "image": + value = self.config['url_artworkPrefix'] % (value) + else: + value = self._cleanData(value) + curActor[tag] = value + cur_actors.append(curActor) + self._setShowData(sid, '_actors', cur_actors) + + def _getShowData(self, sid, language): + """Takes a series ID, gets the epInfo URL and parses the TVDB + XML file into the shows dict in layout: + shows[series_id][season_number][episode_number] + """ + + if self.config['language'] is None: + log().debug('Config language is none, using show language') + if language is None: + raise tvdb_error("config['language'] was None, this should not happen") + getShowInLanguage = language + else: + log().debug( + 'Configured language %s override show language of %s' % ( + self.config['language'], + language + ) + ) + getShowInLanguage = self.config['language'] + + # Parse show information + log().debug('Getting all series data for %s' % (sid)) + seriesInfoEt = self._getetsrc( + self.config['url_seriesInfo'] % (sid, getShowInLanguage) + ) + for curInfo in seriesInfoEt.findall("Series")[0]: + tag = curInfo.tag.lower() + value = curInfo.text + + if value is not None: + if tag in ['banner', 'fanart', 'poster']: + value = self.config['url_artworkPrefix'] % (value) + else: + value = self._cleanData(value) + + self._setShowData(sid, tag, value) + + # Parse banners + if self.config['banners_enabled']: + self._parseBanners(sid) + + # Parse actors + if self.config['actors_enabled']: + self._parseActors(sid) + + # Parse episode data + log().debug('Getting all episodes of %s' % (sid)) + + if self.config['useZip']: + url = self.config['url_epInfo_zip'] % (sid, language) + else: + url = self.config['url_epInfo'] % (sid, language) + + epsEt = self._getetsrc( url, language=language) + + for cur_ep in epsEt.findall("Episode"): + seas_no = int(cur_ep.find('SeasonNumber').text) + ep_no = int(cur_ep.find('EpisodeNumber').text) + for cur_item in cur_ep.getchildren(): + tag = cur_item.tag.lower() + value = cur_item.text + if value is not None: + if tag == 'filename': + value = self.config['url_artworkPrefix'] % (value) + else: + value = self._cleanData(value) + self._setItem(sid, seas_no, ep_no, tag, value) + + def _nameToSid(self, name): + """Takes show name, returns the correct series ID (if the show has + already been grabbed), or grabs all episodes and returns + the correct SID. + """ + if name in self.corrections: + log().debug('Correcting %s to %s' % (name, self.corrections[name]) ) + sid = self.corrections[name] + else: + log().debug('Getting show %s' % (name)) + selected_series = self._getSeries( name ) + sname, sid = selected_series['seriesname'], selected_series['id'] + log().debug('Got %(seriesname)s, id %(id)s' % selected_series) + + self.corrections[name] = sid + self._getShowData(selected_series['id'], selected_series['language']) + + return sid + + def __getitem__(self, key): + """Handles tvdb_instance['seriesname'] calls. + The dict index should be the show id + """ + if isinstance(key, (int, long)): + # Item is integer, treat as show id + if key not in self.shows: + self._getShowData(key, self.config['language']) + return self.shows[key] + + key = key.lower() # make key lower case + sid = self._nameToSid(key) + log().debug('Got series id %s' % (sid)) + return self.shows[sid] + + def __repr__(self): + return str(self.shows) + + +def main(): + """Simple example of using tvdb_api - it just + grabs an episode name interactively. + """ + import logging + logging.basicConfig(level=logging.DEBUG) + + tvdb_instance = Tvdb(interactive=True, cache=False) + print tvdb_instance['Lost']['seriesname'] + print tvdb_instance['Lost'][1][4]['episodename'] + +if __name__ == '__main__': + main() diff --git a/libs/tvdb_api/tvdb_cache.py b/libs/tvdb_api/tvdb_cache.py new file mode 100644 index 00000000..d77c5457 --- /dev/null +++ b/libs/tvdb_api/tvdb_cache.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python +#encoding:utf-8 +#author:dbr/Ben +#project:tvdb_api +#repository:http://github.com/dbr/tvdb_api +#license:unlicense (http://unlicense.org/) + +""" +urllib2 caching handler +Modified from http://code.activestate.com/recipes/491261/ +""" +from __future__ import with_statement + +__author__ = "dbr/Ben" +__version__ = "1.8.2" + +import os +import time +import errno +import httplib +import urllib2 +import StringIO +from hashlib import md5 +from threading import RLock + +cache_lock = RLock() + +def locked_function(origfunc): + """Decorator to execute function under lock""" + def wrapped(*args, **kwargs): + cache_lock.acquire() + try: + return origfunc(*args, **kwargs) + finally: + cache_lock.release() + return wrapped + +def calculate_cache_path(cache_location, url): + """Checks if [cache_location]/[hash_of_url].headers and .body exist + """ + thumb = md5(url).hexdigest() + header = os.path.join(cache_location, thumb + ".headers") + body = os.path.join(cache_location, thumb + ".body") + return header, body + +def check_cache_time(path, max_age): + """Checks if a file has been created/modified in the [last max_age] seconds. + False means the file is too old (or doesn't exist), True means it is + up-to-date and valid""" + if not os.path.isfile(path): + return False + cache_modified_time = os.stat(path).st_mtime + time_now = time.time() + if cache_modified_time < time_now - max_age: + # Cache is old + return False + else: + return True + +@locked_function +def exists_in_cache(cache_location, url, max_age): + """Returns if header AND body cache file exist (and are up-to-date)""" + hpath, bpath = calculate_cache_path(cache_location, url) + if os.path.exists(hpath) and os.path.exists(bpath): + return( + check_cache_time(hpath, max_age) + and check_cache_time(bpath, max_age) + ) + else: + # File does not exist + return False + +@locked_function +def store_in_cache(cache_location, url, response): + """Tries to store response in cache.""" + hpath, bpath = calculate_cache_path(cache_location, url) + try: + outf = open(hpath, "wb") + headers = str(response.info()) + outf.write(headers) + outf.close() + + outf = open(bpath, "wb") + outf.write(response.read()) + outf.close() + except IOError: + return True + else: + return False + +@locked_function +def delete_from_cache(cache_location, url): + """Deletes a response in cache.""" + hpath, bpath = calculate_cache_path(cache_location, url) + try: + if os.path.exists(hpath): + os.remove(hpath) + if os.path.exists(bpath): + os.remove(bpath) + except IOError: + return True + else: + return False + +class CacheHandler(urllib2.BaseHandler): + """Stores responses in a persistant on-disk cache. + + If a subsequent GET request is made for the same URL, the stored + response is returned, saving time, resources and bandwidth + """ + @locked_function + def __init__(self, cache_location, max_age = 21600): + """The location of the cache directory""" + self.max_age = max_age + self.cache_location = cache_location + if not os.path.exists(self.cache_location): + try: + os.mkdir(self.cache_location) + except OSError, e: + if e.errno == errno.EEXIST and os.path.isdir(self.cache_location): + # File exists, and it's a directory, + # another process beat us to creating this dir, that's OK. + pass + else: + # Our target dir is already a file, or different error, + # relay the error! + raise + + def default_open(self, request): + """Handles GET requests, if the response is cached it returns it + """ + if request.get_method() is not "GET": + return None # let the next handler try to handle the request + + if exists_in_cache( + self.cache_location, request.get_full_url(), self.max_age + ): + return CachedResponse( + self.cache_location, + request.get_full_url(), + set_cache_header = True + ) + else: + return None + + def http_response(self, request, response): + """Gets a HTTP response, if it was a GET request and the status code + starts with 2 (200 OK etc) it caches it and returns a CachedResponse + """ + if (request.get_method() == "GET" + and str(response.code).startswith("2") + ): + if 'x-local-cache' not in response.info(): + # Response is not cached + set_cache_header = store_in_cache( + self.cache_location, + request.get_full_url(), + response + ) + else: + set_cache_header = True + + return CachedResponse( + self.cache_location, + request.get_full_url(), + set_cache_header = set_cache_header + ) + else: + return response + +class CachedResponse(StringIO.StringIO): + """An urllib2.response-like object for cached responses. + + To determine if a response is cached or coming directly from + the network, check the x-local-cache header rather than the object type. + """ + + @locked_function + def __init__(self, cache_location, url, set_cache_header=True): + self.cache_location = cache_location + hpath, bpath = calculate_cache_path(cache_location, url) + + StringIO.StringIO.__init__(self, file(bpath, "rb").read()) + + self.url = url + self.code = 200 + self.msg = "OK" + headerbuf = file(hpath, "rb").read() + if set_cache_header: + headerbuf += "x-local-cache: %s\r\n" % (bpath) + self.headers = httplib.HTTPMessage(StringIO.StringIO(headerbuf)) + + def info(self): + """Returns headers + """ + return self.headers + + def geturl(self): + """Returns original URL + """ + return self.url + + @locked_function + def recache(self): + new_request = urllib2.urlopen(self.url) + set_cache_header = store_in_cache( + self.cache_location, + new_request.url, + new_request + ) + CachedResponse.__init__(self, self.cache_location, self.url, True) + + @locked_function + def delete_cache(self): + delete_from_cache( + self.cache_location, + self.url + ) + + +if __name__ == "__main__": + def main(): + """Quick test/example of CacheHandler""" + opener = urllib2.build_opener(CacheHandler("/tmp/")) + response = opener.open("http://google.com") + print response.headers + print "Response:", response.read() + + response.recache() + print response.headers + print "After recache:", response.read() + + # Test usage in threads + from threading import Thread + class CacheThreadTest(Thread): + lastdata = None + def run(self): + req = opener.open("http://google.com") + newdata = req.read() + if self.lastdata is None: + self.lastdata = newdata + assert self.lastdata == newdata, "Data was not consistent, uhoh" + req.recache() + threads = [CacheThreadTest() for x in range(50)] + print "Starting threads" + [t.start() for t in threads] + print "..done" + print "Joining threads" + [t.join() for t in threads] + print "..done" + main() diff --git a/libs/tvdb_api/tvdb_exceptions.py b/libs/tvdb_api/tvdb_exceptions.py new file mode 100644 index 00000000..cacbb936 --- /dev/null +++ b/libs/tvdb_api/tvdb_exceptions.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python +#encoding:utf-8 +#author:dbr/Ben +#project:tvdb_api +#repository:http://github.com/dbr/tvdb_api +#license:unlicense (http://unlicense.org/) + +"""Custom exceptions used or raised by tvdb_api +""" + +__author__ = "dbr/Ben" +__version__ = "1.8.2" + +__all__ = ["tvdb_error", "tvdb_userabort", "tvdb_shownotfound", +"tvdb_seasonnotfound", "tvdb_episodenotfound", "tvdb_attributenotfound"] + +class tvdb_exception(Exception): + """Any exception generated by tvdb_api + """ + pass + +class tvdb_error(tvdb_exception): + """An error with thetvdb.com (Cannot connect, for example) + """ + pass + +class tvdb_userabort(tvdb_exception): + """User aborted the interactive selection (via + the q command, ^c etc) + """ + pass + +class tvdb_shownotfound(tvdb_exception): + """Show cannot be found on thetvdb.com (non-existant show) + """ + pass + +class tvdb_seasonnotfound(tvdb_exception): + """Season cannot be found on thetvdb.com + """ + pass + +class tvdb_episodenotfound(tvdb_exception): + """Episode cannot be found on thetvdb.com + """ + pass + +class tvdb_attributenotfound(tvdb_exception): + """Raised if an episode does not have the requested + attribute (such as a episode name) + """ + pass diff --git a/libs/tvdb_api/tvdb_ui.py b/libs/tvdb_api/tvdb_ui.py new file mode 100644 index 00000000..a4b6e95d --- /dev/null +++ b/libs/tvdb_api/tvdb_ui.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python +#encoding:utf-8 +#author:dbr/Ben +#project:tvdb_api +#repository:http://github.com/dbr/tvdb_api +#license:unlicense (http://unlicense.org/) + +"""Contains included user interfaces for Tvdb show selection. + +A UI is a callback. A class, it's __init__ function takes two arguments: + +- config, which is the Tvdb config dict, setup in tvdb_api.py +- log, which is Tvdb's logger instance (which uses the logging module). You can +call log.info() log.warning() etc + +It must have a method "selectSeries", this is passed a list of dicts, each dict +contains the the keys "name" (human readable show name), and "sid" (the shows +ID as on thetvdb.com). For example: + +[{'name': u'Lost', 'sid': u'73739'}, + {'name': u'Lost Universe', 'sid': u'73181'}] + +The "selectSeries" method must return the appropriate dict, or it can raise +tvdb_userabort (if the selection is aborted), tvdb_shownotfound (if the show +cannot be found). + +A simple example callback, which returns a random series: + +>>> import random +>>> from tvdb_ui import BaseUI +>>> class RandomUI(BaseUI): +... def selectSeries(self, allSeries): +... import random +... return random.choice(allSeries) + +Then to use it.. + +>>> from tvdb_api import Tvdb +>>> t = Tvdb(custom_ui = RandomUI) +>>> random_matching_series = t['Lost'] +>>> type(random_matching_series) + +""" + +__author__ = "dbr/Ben" +__version__ = "1.8.2" + +import logging +import warnings + +from tvdb_exceptions import tvdb_userabort + +def log(): + return logging.getLogger(__name__) + +class BaseUI: + """Default non-interactive UI, which auto-selects first results + """ + def __init__(self, config, log = None): + self.config = config + if log is not None: + warnings.warn("the UI's log parameter is deprecated, instead use\n" + "use import logging; logging.getLogger('ui').info('blah')\n" + "The self.log attribute will be removed in the next version") + self.log = logging.getLogger(__name__) + + def selectSeries(self, allSeries): + return allSeries[0] + + +class ConsoleUI(BaseUI): + """Interactively allows the user to select a show from a console based UI + """ + + def _displaySeries(self, allSeries, limit = 6): + """Helper function, lists series with corresponding ID + """ + if limit is not None: + toshow = allSeries[:limit] + else: + toshow = allSeries + + print "TVDB Search Results:" + for i, cshow in enumerate(toshow): + i_show = i + 1 # Start at more human readable number 1 (not 0) + log().debug('Showing allSeries[%s], series %s)' % (i_show, allSeries[i]['seriesname'])) + if i == 0: + extra = " (default)" + else: + extra = "" + + print "%s -> %s [%s] # http://thetvdb.com/?tab=series&id=%s&lid=%s%s" % ( + i_show, + cshow['seriesname'].encode("UTF-8", "ignore"), + cshow['language'].encode("UTF-8", "ignore"), + str(cshow['id']), + cshow['lid'], + extra + ) + + def selectSeries(self, allSeries): + self._displaySeries(allSeries) + + if len(allSeries) == 1: + # Single result, return it! + print "Automatically selecting only result" + return allSeries[0] + + if self.config['select_first'] is True: + print "Automatically returning first search result" + return allSeries[0] + + while True: # return breaks this loop + try: + print "Enter choice (first number, return for default, 'all', ? for help):" + ans = raw_input() + except KeyboardInterrupt: + raise tvdb_userabort("User aborted (^c keyboard interupt)") + except EOFError: + raise tvdb_userabort("User aborted (EOF received)") + + log().debug('Got choice of: %s' % (ans)) + try: + selected_id = int(ans) - 1 # The human entered 1 as first result, not zero + except ValueError: # Input was not number + if len(ans.strip()) == 0: + # Default option + log().debug('Default option, returning first series') + return allSeries[0] + if ans == "q": + log().debug('Got quit command (q)') + raise tvdb_userabort("User aborted ('q' quit command)") + elif ans == "?": + print "## Help" + print "# Enter the number that corresponds to the correct show." + print "# a - display all results" + print "# all - display all results" + print "# ? - this help" + print "# q - abort tvnamer" + print "# Press return with no input to select first result" + elif ans.lower() in ["a", "all"]: + self._displaySeries(allSeries, limit = None) + else: + log().debug('Unknown keypress %s' % (ans)) + else: + log().debug('Trying to return ID: %d' % (selected_id)) + try: + return allSeries[selected_id] + except IndexError: + log().debug('Invalid show number entered!') + print "Invalid number (%s) selected!" + self._displaySeries(allSeries) +