diff --git a/couchpotato/core/media/_base/media/main.py b/couchpotato/core/media/_base/media/main.py index 01dc0f1..28221ba 100755 --- a/couchpotato/core/media/_base/media/main.py +++ b/couchpotato/core/media/_base/media/main.py @@ -273,10 +273,6 @@ class MediaPlugin(MediaBase): for x in filter_by: media_ids = [n for n in media_ids if n in filter_by[x]] - total_count = len(media_ids) - if total_count == 0: - return 0, [] - offset = 0 limit = -1 if limit_offset: @@ -306,11 +302,30 @@ class MediaPlugin(MediaBase): media_ids.remove(media_id) if len(media_ids) == 0 or len(medias) == limit: break - return total_count, medias + # Sort media by type and return result + result = {} + + # Create keys for media types we are listing + if types: + for media_type in types: + result['%ss' % media_type] = [] + else: + for media_type in fireEvent('media.types', merge = True): + result['%ss' % media_type] = [] + + total_count = len(medias) + + if total_count == 0: + return 0, result + + for kind in medias: + result['%ss' % kind['type']].append(kind) + + return total_count, result def listView(self, **kwargs): - total_movies, movies = self.list( + total_count, result = self.list( types = splitString(kwargs.get('type')), status = splitString(kwargs.get('status')), release_status = splitString(kwargs.get('release_status')), @@ -321,12 +336,12 @@ class MediaPlugin(MediaBase): search = kwargs.get('search') ) - return { - 'success': True, - 'empty': len(movies) == 0, - 'total': total_movies, - 'movies': movies, - } + results = result + results['success'] = True + results['empty'] = len(result) == 0 + results['total'] = total_count + + return results def addSingleListView(self): diff --git a/couchpotato/core/media/show/__init__.py b/couchpotato/core/media/show/__init__.py index 89af436..89bfef6 100644 --- a/couchpotato/core/media/show/__init__.py +++ b/couchpotato/core/media/show/__init__.py @@ -2,5 +2,10 @@ from couchpotato.core.media import MediaBase class ShowTypeBase(MediaBase): - _type = 'show' + + def getType(self): + if hasattr(self, 'type') and self.type != self._type: + return '%s.%s' % (self._type, self.type) + + return self._type diff --git a/couchpotato/core/media/show/_base/episode.py b/couchpotato/core/media/show/_base/episode.py new file mode 100755 index 0000000..400c8e7 --- /dev/null +++ b/couchpotato/core/media/show/_base/episode.py @@ -0,0 +1,109 @@ +from couchpotato import get_db +from couchpotato.core.event import addEvent, fireEvent, fireEventAsync +from couchpotato.core.logger import CPLog +from couchpotato.core.helpers.variable import tryInt +from couchpotato.core.media import MediaBase + + +log = CPLog(__name__) + +autoload = 'Episode' + + +class Episode(MediaBase): + + def __init__(self): + addEvent('show.episode.add', self.add) + addEvent('show.episode.update', self.update) + addEvent('show.episode.update_extras', self.updateExtras) + + def add(self, parent_id, info = None, update_after = True, status = None): + if not info: info = {} + + identifiers = info.pop('identifiers', None) + + if not identifiers: + log.warning('Unable to add episode, missing identifiers (info provider mismatch?)') + return + + # Add Season + episode_info = { + '_t': 'media', + 'type': 'show.episode', + 'identifiers': identifiers, + 'status': status if status else 'active', + 'parent_id': parent_id, + 'info': info, # Returned dict by providers + } + + # Check if season already exists + existing_episode = fireEvent('media.with_identifiers', identifiers, with_doc = True, single = True) + + db = get_db() + + if existing_episode: + s = existing_episode['doc'] + s.update(episode_info) + + episode = db.update(s) + else: + episode = db.insert(episode_info) + + # Update library info + if update_after is not False: + handle = fireEventAsync if update_after is 'async' else fireEvent + handle('show.episode.update_extras', episode, info, store = True, single = True) + + return episode + + def update(self, media_id = None, identifiers = None, info = None): + if not info: info = {} + + if self.shuttingDown(): + return + + db = get_db() + + episode = db.get('id', media_id) + + # Get new info + if not info: + season = db.get('id', episode['parent_id']) + show = db.get('id', season['parent_id']) + + info = fireEvent( + 'episode.info', show.get('identifiers'), { + 'season_identifiers': season.get('identifiers'), + 'season_number': season.get('info', {}).get('number'), + + 'episode_identifiers': episode.get('identifiers'), + 'episode_number': episode.get('info', {}).get('number'), + + 'absolute_number': episode.get('info', {}).get('absolute_number') + }, + merge = True + ) + + info['season_number'] = season.get('info', {}).get('number') + + identifiers = info.pop('identifiers', None) or identifiers + + # Update/create media + episode['identifiers'].update(identifiers) + episode.update({'info': info}) + + self.updateExtras(episode, info) + + db.update(episode) + return episode + + def updateExtras(self, episode, info, store=False): + db = get_db() + + # Get images + image_urls = info.get('images', []) + existing_files = episode.get('files', {}) + self.getPoster(image_urls, existing_files) + + if store: + db.update(episode) diff --git a/couchpotato/core/media/show/_base/main.py b/couchpotato/core/media/show/_base/main.py old mode 100644 new mode 100755 index 7d7e637..e27e489 --- a/couchpotato/core/media/show/_base/main.py +++ b/couchpotato/core/media/show/_base/main.py @@ -4,11 +4,9 @@ import traceback from couchpotato import get_db from couchpotato.api import addApiView from couchpotato.core.event import fireEvent, fireEventAsync, addEvent -from couchpotato.core.helpers.encoding import simplifyString -from couchpotato.core.helpers.variable import getTitle +from couchpotato.core.helpers.variable import getTitle, find from couchpotato.core.logger import CPLog from couchpotato.core.media import MediaBase -from qcond import QueryCondenser log = CPLog(__name__) @@ -17,10 +15,10 @@ log = CPLog(__name__) class ShowBase(MediaBase): _type = 'show' - query_condenser = QueryCondenser() def __init__(self): super(ShowBase, self).__init__() + self.initType() addApiView('show.add', self.addView, docs = { 'desc': 'Add new show to the wanted list', @@ -33,9 +31,8 @@ class ShowBase(MediaBase): }) addEvent('show.add', self.add) - addEvent('show.update_info', self.updateInfo) - - addEvent('media.search_query', self.query) + addEvent('show.update', self.update) + addEvent('show.update_extras', self.updateExtras) def addView(self, **kwargs): add_dict = self.add(params = kwargs) @@ -48,8 +45,6 @@ class ShowBase(MediaBase): def add(self, params = None, force_readd = True, search_after = True, update_after = True, notify_after = True, status = None): if not params: params = {} - db = get_db() - # Identifiers if not params.get('identifiers'): msg = 'Can\'t add show without at least 1 identifier.' @@ -61,124 +56,13 @@ class ShowBase(MediaBase): if not info or (info and len(info.get('titles', [])) == 0): info = fireEvent('show.info', merge = True, identifiers = params.get('identifiers')) - # Set default title - def_title = self.getDefaultTitle(info) - - # Default profile and category - default_profile = {} - if not params.get('profile_id'): - default_profile = fireEvent('profile.default', single = True) - cat_id = params.get('category_id') - # Add Show try: - media = { - '_t': 'media', - 'type': 'show', - 'title': def_title, - 'identifiers': info.get('identifiers'), - 'status': status if status else 'active', - 'profile_id': params.get('profile_id', default_profile.get('_id')), - 'category_id': cat_id if cat_id is not None and len(cat_id) > 0 and cat_id != '-1' else None - } - - # TODO: stuff below is mostly a copy of what is done in movie - # Can we make a base function to do this stuff? - - # Remove season info for later use (save separately) - seasons_info = info.get('seasons', {}) - identifiers = info.get('identifiers', {}) - - # Make sure we don't nest in_wanted data - del info['identifiers'] - try: del info['in_wanted'] - except: pass - try: del info['in_library'] - except: pass - try: del info['identifiers'] - except: pass - try: del info['seasons'] - except: pass - - media['info'] = info - - new = False - try: - m = fireEvent('media.with_identifiers', params.get('identifiers'), with_doc = True, single = True)['doc'] - except: - new = True - m = db.insert(media) - - # Update dict to be usable - m.update(media) - - - added = True - do_search = False - search_after = search_after and self.conf('search_on_add', section = 'showsearcher') - onComplete = None - - if new: - if search_after: - onComplete = self.createOnComplete(m['_id']) - search_after = False - elif force_readd: - - # Clean snatched history - for release in fireEvent('release.for_media', m['_id'], single = True): - if release.get('status') in ['downloaded', 'snatched', 'done']: - if params.get('ignore_previous', False): - release['status'] = 'ignored' - db.update(release) - else: - fireEvent('release.delete', release['_id'], single = True) - - m['profile_id'] = params.get('profile_id', default_profile.get('id')) - m['category_id'] = media.get('category_id') - m['last_edit'] = int(time.time()) - - do_search = True - db.update(m) - else: - try: del params['info'] - except: pass - log.debug('Show already exists, not updating: %s', params) - added = False - - # Trigger update info - if added and update_after: - # Do full update to get images etc - fireEventAsync('show.update_info', m['_id'], info = info, on_complete = onComplete) - - # Remove releases - for rel in fireEvent('release.for_media', m['_id'], single = True): - if rel['status'] is 'available': - db.delete(rel) - - movie_dict = fireEvent('media.get', m['_id'], single = True) - - if do_search and search_after: - onComplete = self.createOnComplete(m['_id']) - onComplete() - - # Add Seasons - for season_nr in seasons_info: - - season_info = seasons_info[season_nr] - episodes = season_info.get('episodes', {}) - - season = fireEvent('show.season.add', m.get('_id'), season_info, single = True) - - # Add Episodes - for episode_nr in episodes: - - episode_info = episodes[episode_nr] - episode_info['season_number'] = season_nr - fireEvent('show.episode.add', season.get('_id'), episode_info, single = True) + m, added = self.create(info, params, force_readd, search_after, update_after) + result = fireEvent('media.get', m['_id'], single = True) if added and notify_after: - if params.get('title'): message = 'Successfully added "%s" to your wanted list.' % params.get('title', '') else: @@ -187,17 +71,121 @@ class ShowBase(MediaBase): message = 'Successfully added "%s" to your wanted list.' % title else: message = 'Successfully added to your wanted list.' - fireEvent('notify.frontend', type = 'show.added', data = movie_dict, message = message) + fireEvent('notify.frontend', type = 'show.added', data = result, message = message) - return movie_dict + return result except: log.error('Failed adding media: %s', traceback.format_exc()) - def updateInfo(self, media_id = None, identifiers = None, info = None): - if not info: info = {} - if not identifiers: identifiers = {} + def create(self, info, params = None, force_readd = True, search_after = True, update_after = True, notify_after = True, status = None): + # Set default title + def_title = self.getDefaultTitle(info) + + # Default profile and category + default_profile = {} + if not params.get('profile_id'): + default_profile = fireEvent('profile.default', single = True) + + cat_id = params.get('category_id') + + media = { + '_t': 'media', + 'type': 'show', + 'title': def_title, + 'identifiers': info.get('identifiers'), + 'status': status if status else 'active', + 'profile_id': params.get('profile_id', default_profile.get('_id')), + 'category_id': cat_id if cat_id is not None and len(cat_id) > 0 and cat_id != '-1' else None + } + + identifiers = info.pop('identifiers', {}) + seasons = info.pop('seasons', {}) + + # Update media with info + self.updateInfo(media, info) + + existing_show = fireEvent('media.with_identifiers', params.get('identifiers'), with_doc = True) + + db = get_db() + + if existing_show: + s = existing_show['doc'] + s.update(media) + + show = db.update(s) + else: + show = db.insert(media) + + # Update dict to be usable + show.update(media) + + added = True + do_search = False + search_after = search_after and self.conf('search_on_add', section = 'showsearcher') + onComplete = None + + if existing_show: + if search_after: + onComplete = self.createOnComplete(show['_id']) + + search_after = False + elif force_readd: + # Clean snatched history + for release in fireEvent('release.for_media', show['_id'], single = True): + if release.get('status') in ['downloaded', 'snatched', 'done']: + if params.get('ignore_previous', False): + release['status'] = 'ignored' + db.update(release) + else: + fireEvent('release.delete', release['_id'], single = True) + + show['profile_id'] = params.get('profile_id', default_profile.get('id')) + show['category_id'] = media.get('category_id') + show['last_edit'] = int(time.time()) + do_search = True + db.update(show) + else: + params.pop('info', None) + log.debug('Show already exists, not updating: %s', params) + added = False + + # Create episodes + self.createEpisodes(show, seasons) + + # Trigger update info + if added and update_after: + # Do full update to get images etc + fireEventAsync('show.update_extras', show.copy(), info, store = True, on_complete = onComplete) + + # Remove releases + for rel in fireEvent('release.for_media', show['_id'], single = True): + if rel['status'] is 'available': + db.delete(rel) + + if do_search and search_after: + onComplete = self.createOnComplete(show['_id']) + onComplete() + + return show, added + + def createEpisodes(self, m, seasons_info): + # Add Seasons + for season_nr in seasons_info: + season_info = seasons_info[season_nr] + episodes = season_info.get('episodes', {}) + + season = fireEvent('show.season.add', m.get('_id'), season_info, update_after = False, single = True) + + # Add Episodes + for episode_nr in episodes: + episode_info = episodes[episode_nr] + episode_info['season_number'] = season_nr + + fireEvent('show.episode.add', season.get('_id'), episode_info, update_after = False, single = True) + + def update(self, media_id = None, media = None, identifiers = None, info = None): """ Update movie information inside media['doc']['info'] @@ -212,68 +200,92 @@ class ShowBase(MediaBase): @return: dict, with media """ + if not info: info = {} + if not identifiers: identifiers = {} + + db = get_db() + if self.shuttingDown(): return + if media is None and media_id: + media = db.get('id', media_id) + else: + log.error('missing "media" and "media_id" parameters, unable to update') + return + + if not info: + info = fireEvent('show.info', identifiers = media.get('identifiers'), merge = True) + try: - db = get_db() - - if media_id: - media = db.get('id', media_id) - else: - media = db.get('media', identifiers, with_doc = True)['doc'] - - if not info: - info = fireEvent('show.info', identifiers = media.get('identifiers'), merge = True) - - # Don't need those here - try: del info['seasons'] - except: pass - try: del info['identifiers'] - except: pass - 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 show info to work with: %s', media.get('identifier')) - return False - - # Update basic info - media['info'] = info - - # Update image file - image_urls = info.get('images', []) - existing_files = media.get('files', {}) - self.getPoster(image_urls, existing_files) + identifiers = info.pop('identifiers', {}) + seasons = info.pop('seasons', {}) - db.update(media) + self.updateInfo(media, info) + self.updateEpisodes(media, seasons) + self.updateExtras(media, info) + db.update(media) return media except: log.error('Failed update media: %s', traceback.format_exc()) return {} - def query(self, media, first = True, condense = True, **kwargs): - if media.get('type') != 'show': - return + def updateInfo(self, media, info): + db = get_db() + + # Remove season info for later use (save separately) + info.pop('in_wanted', None) + info.pop('in_library', None) + + if not info or len(info) == 0: + log.error('Could not update, no show info to work with: %s', media.get('identifier')) + return False + + # Update basic info + media['info'] = info + + def updateEpisodes(self, media, seasons): + # Fetch current season/episode tree + show_tree = fireEvent('library.tree', media_id = media['_id'], single = True) - titles = media['info']['titles'] + # Update seasons + for season_num in seasons: + season_info = seasons[season_num] + episodes = season_info.get('episodes', {}) - if condense: - # Use QueryCondenser to build a list of optimal search titles - condensed_titles = self.query_condenser.distinct(titles) + # Find season that matches number + season = find(lambda s: s.get('info', {}).get('number', 0) == season_num, show_tree.get('seasons', [])) - if condensed_titles: - # Use condensed titles if we got a valid result - titles = condensed_titles - else: - # Fallback to simplifying titles - titles = [simplifyString(title) for title in titles] + if not season: + log.warning('Unable to find season "%s"', season_num) + continue - if first: - return titles[0] if titles else None + # Update season + fireEvent('show.season.update', season['_id'], info = season_info, single = True) - return titles + # Update episodes + for episode_num in episodes: + episode_info = episodes[episode_num] + episode_info['season_number'] = season_num + + # Find episode that matches number + episode = find(lambda s: s.get('info', {}).get('number', 0) == episode_num, season.get('episodes', [])) + + if not episode: + log.debug('Creating new episode %s in season %s', (episode_num, season_num)) + fireEvent('show.episode.add', season.get('_id'), episode_info, update_after = False, single = True) + continue + + fireEvent('show.episode.update', episode['_id'], info = episode_info, single = True) + + def updateExtras(self, media, info, store=False): + db = get_db() + + # Update image file + image_urls = info.get('images', []) + self.getPoster(media, image_urls) + + if store: + db.update(media) diff --git a/couchpotato/core/media/show/_base/season.py b/couchpotato/core/media/show/_base/season.py new file mode 100755 index 0000000..e41e460 --- /dev/null +++ b/couchpotato/core/media/show/_base/season.py @@ -0,0 +1,94 @@ +from couchpotato import get_db +from couchpotato.core.event import addEvent, fireEvent, fireEventAsync +from couchpotato.core.logger import CPLog +from couchpotato.core.helpers.variable import tryInt +from couchpotato.core.media import MediaBase + + +log = CPLog(__name__) + +autoload = 'Season' + + +class Season(MediaBase): + + def __init__(self): + addEvent('show.season.add', self.add) + addEvent('show.season.update', self.update) + addEvent('show.season.update_extras', self.updateExtras) + + def add(self, parent_id, info = None, update_after = True, status = None): + if not info: info = {} + + identifiers = info.pop('identifiers', None) + info.pop('episodes', None) + + # Add Season + season_info = { + '_t': 'media', + 'type': 'show.season', + 'identifiers': identifiers, + 'status': status if status else 'active', + 'parent_id': parent_id, + 'info': info, # Returned dict by providers + } + + # Check if season already exists + existing_season = fireEvent('media.with_identifiers', identifiers, with_doc = True, single = True) + + db = get_db() + + if existing_season: + s = existing_season['doc'] + s.update(season_info) + + season = db.update(s) + else: + season = db.insert(season_info) + + # Update library info + if update_after is not False: + handle = fireEventAsync if update_after is 'async' else fireEvent + handle('show.season.update_extras', season, info, store = True, single = True) + + return season + + def update(self, media_id = None, identifiers = None, info = None): + if not info: info = {} + + if self.shuttingDown(): + return + + db = get_db() + + season = db.get('id', media_id) + show = db.get('id', season['parent_id']) + + # Get new info + if not info: + info = fireEvent('season.info', show.get('identifiers'), { + 'season_number': season.get('info', {}).get('number', 0) + }, merge = True) + + identifiers = info.pop('identifiers', None) or identifiers + info.pop('episodes', None) + + # Update/create media + season['identifiers'].update(identifiers) + season.update({'info': info}) + + self.updateExtras(season, info) + + db.update(season) + return season + + def updateExtras(self, season, info, store=False): + db = get_db() + + # Get images + image_urls = info.get('images', []) + existing_files = season.get('files', {}) + self.getPoster(image_urls, existing_files) + + if store: + db.update(season) diff --git a/couchpotato/core/media/show/_base/static/1_wanted.js b/couchpotato/core/media/show/_base/static/1_wanted.js old mode 100644 new mode 100755 index e7b308b..2400071 --- a/couchpotato/core/media/show/_base/static/1_wanted.js +++ b/couchpotato/core/media/show/_base/static/1_wanted.js @@ -12,7 +12,7 @@ Page.Shows = new Class({ if(!self.wanted){ // Wanted movies - self.wanted = new MovieList({ + self.wanted = new ShowList({ 'identifier': 'wanted', 'status': 'active', 'type': 'show', diff --git a/couchpotato/core/media/show/_base/static/episode.actions.js b/couchpotato/core/media/show/_base/static/episode.actions.js new file mode 100755 index 0000000..cc46ce3 --- /dev/null +++ b/couchpotato/core/media/show/_base/static/episode.actions.js @@ -0,0 +1,474 @@ +var EpisodeAction = new Class({ + + Implements: [Options], + + class_name: 'item-action icon2', + + initialize: function(episode, options){ + var self = this; + self.setOptions(options); + + self.show = episode.show; + self.episode = episode; + + self.create(); + if(self.el) + self.el.addClass(self.class_name) + }, + + create: function(){}, + + disable: function(){ + if(this.el) + this.el.addClass('disable') + }, + + enable: function(){ + if(this.el) + this.el.removeClass('disable') + }, + + getTitle: function(){ + var self = this; + + try { + return self.show.getTitle(); + } + catch(e){ + try { + return self.show.original_title ? self.show.original_title : self.show.titles[0]; + } + catch(e){ + return 'Unknown'; + } + } + }, + + get: function(key){ + var self = this; + try { + return self.show.get(key) + } + catch(e){ + return self.show[key] + } + }, + + createMask: function(){ + var self = this; + self.mask = new Element('div.mask', { + 'styles': { + 'z-index': '1' + } + }).inject(self.show, 'top').fade('hide'); + }, + + toElement: function(){ + return this.el || null + } + +}); + +var EA = {}; + +EA.IMDB = new Class({ + + Extends: EpisodeAction, + id: null, + + create: function(){ + var self = this; + + self.id = self.show.getIdentifier ? self.show.getIdentifier() : self.get('imdb'); + + self.el = new Element('a.imdb', { + 'title': 'Go to the IMDB page of ' + self.getTitle(), + 'href': 'http://www.imdb.com/title/'+self.id+'/', + 'target': '_blank' + }); + + if(!self.id) self.disable(); + } + +}); + +EA.Release = new Class({ + + Extends: EpisodeAction, + + create: function(){ + var self = this; + + self.el = new Element('a.releases.download', { + 'title': 'Show the releases that are available for ' + self.getTitle(), + 'events': { + 'click': self.toggle.bind(self) + } + }); + + self.options = new Element('div.episode-options').inject(self.episode.el); + + if(!self.episode.data.releases || self.episode.data.releases.length == 0) + self.el.hide(); + else + self.showHelper(); + + App.on('show.searcher.ended', function(notification){ + if(self.show.data._id != notification.data._id) return; + + self.releases = null; + if(self.options_container){ + self.options_container.destroy(); + self.options_container = null; + } + }); + + }, + + toggle: function(e){ + var self = this; + + if(self.options && self.options.hasClass('expanded')) { + self.close(); + } else { + self.open(); + } + }, + + open: function(e){ + var self = this; + + if(e) + (e).preventDefault(); + + self.createReleases(); + + }, + + close: function(e) { + var self = this; + + if(e) + (e).preventDefault(); + + self.options.setStyle('height', 0) + .removeClass('expanded'); + }, + + createReleases: function(){ + var self = this; + + if(!self.releases_table){ + self.options.adopt( + self.releases_table = new Element('div.releases.table') + ); + + // Header + new Element('div.item.head').adopt( + new Element('span.name', {'text': 'Release name'}), + new Element('span.status', {'text': 'Status'}), + new Element('span.quality', {'text': 'Quality'}), + new Element('span.size', {'text': 'Size'}), + new Element('span.age', {'text': 'Age'}), + new Element('span.score', {'text': 'Score'}), + new Element('span.provider', {'text': 'Provider'}) + ).inject(self.releases_table); + + if(self.episode.data.releases) + self.episode.data.releases.each(function(release){ + + var quality = Quality.getQuality(release.quality) || {}, + info = release.info || {}, + provider = self.get(release, 'provider') + (info['provider_extra'] ? self.get(release, 'provider_extra') : ''); + + var release_name = self.get(release, 'name'); + if(release.files && release.files.length > 0){ + try { + var movie_file = release.files.filter(function(file){ + var type = File.Type.get(file.type_id); + return type && type.identifier == 'movie' + }).pick(); + release_name = movie_file.path.split(Api.getOption('path_sep')).getLast(); + } + catch(e){} + } + + // Create release + release['el'] = new Element('div', { + 'class': 'item '+release.status, + 'id': 'release_'+release._id + }).adopt( + new Element('span.name', {'text': release_name, 'title': release_name}), + new Element('span.status', {'text': release.status, 'class': 'status '+release.status}), + new Element('span.quality', {'text': quality.label + (release.is_3d ? ' 3D' : '') || 'n/a'}), + new Element('span.size', {'text': info['size'] ? Math.floor(self.get(release, 'size')) : 'n/a'}), + new Element('span.age', {'text': self.get(release, 'age')}), + new Element('span.score', {'text': self.get(release, 'score')}), + new Element('span.provider', { 'text': provider, 'title': provider }), + info['detail_url'] ? new Element('a.info.icon2', { + 'href': info['detail_url'], + 'target': '_blank' + }) : new Element('a'), + new Element('a.download.icon2', { + 'events': { + 'click': function(e){ + (e).preventDefault(); + if(!this.hasClass('completed')) + self.download(release); + } + } + }), + new Element('a.delete.icon2', { + 'events': { + 'click': function(e){ + (e).preventDefault(); + self.ignore(release); + } + } + }) + ).inject(self.releases_table); + + if(release.status == 'ignored' || release.status == 'failed' || release.status == 'snatched'){ + if(!self.last_release || (self.last_release && self.last_release.status != 'snatched' && release.status == 'snatched')) + self.last_release = release; + } + else if(!self.next_release && release.status == 'available'){ + self.next_release = release; + } + + var update_handle = function(notification) { + if(notification.data._id != release._id) return; + + var q = self.show.quality.getElement('.q_' + release.quality), + new_status = notification.data.status; + + release.el.set('class', 'item ' + new_status); + + var status_el = release.el.getElement('.release_status'); + status_el.set('class', 'release_status ' + new_status); + status_el.set('text', new_status); + + if(!q && (new_status == 'snatched' || new_status == 'seeding' || new_status == 'done')) + q = self.addQuality(release.quality_id); + + if(q && !q.hasClass(new_status)) { + q.removeClass(release.status).addClass(new_status); + q.set('title', q.get('title').replace(release.status, new_status)); + } + }; + + App.on('release.update_status', update_handle); + + }); + + if(self.last_release) + self.releases_table.getElements('#release_'+self.last_release._id).addClass('last_release'); + + if(self.next_release) + self.releases_table.getElements('#release_'+self.next_release._id).addClass('next_release'); + + if(self.next_release || (self.last_release && ['ignored', 'failed'].indexOf(self.last_release.status) === false)){ + + self.trynext_container = new Element('div.buttons.try_container').inject(self.releases_table, 'top'); + + var nr = self.next_release, + lr = self.last_release; + + self.trynext_container.adopt( + new Element('span.or', { + 'text': 'If anything went wrong, download' + }), + lr ? new Element('a.button.orange', { + 'text': 'the same release again', + 'events': { + 'click': function(){ + self.download(lr); + } + } + }) : null, + nr && lr ? new Element('span.or', { + 'text': ',' + }) : null, + nr ? [new Element('a.button.green', { + 'text': lr ? 'another release' : 'the best release', + 'events': { + 'click': function(){ + self.download(nr); + } + } + }), + new Element('span.or', { + 'text': 'or pick one below' + })] : null + ) + } + + self.last_release = null; + self.next_release = null; + + self.episode.el.addEvent('outerClick', function(){ + self.close(); + }); + } + + self.options.setStyle('height', self.releases_table.getSize().y) + .addClass('expanded'); + + }, + + showHelper: function(e){ + var self = this; + if(e) + (e).preventDefault(); + + var has_available = false, + has_snatched = false; + + if(self.episode.data.releases) + self.episode.data.releases.each(function(release){ + if(has_available && has_snatched) return; + + if(['snatched', 'downloaded', 'seeding'].contains(release.status)) + has_snatched = true; + + if(['available'].contains(release.status)) + has_available = true; + + }); + + if(has_available || has_snatched){ + + self.trynext_container = new Element('div.buttons.trynext').inject(self.show.info_container); + + self.trynext_container.adopt( + has_available ? [new Element('a.icon2.readd', { + 'text': has_snatched ? 'Download another release' : 'Download the best release', + 'events': { + 'click': self.tryNextRelease.bind(self) + } + }), + new Element('a.icon2.download', { + 'text': 'pick one yourself', + 'events': { + 'click': function(){ + self.show.quality.fireEvent('click'); + } + } + })] : null, + new Element('a.icon2.completed', { + 'text': 'mark this movie done', + 'events': { + 'click': self.markMovieDone.bind(self) + } + }) + ) + } + + }, + + get: function(release, type){ + return (release.info && release.info[type] !== undefined) ? release.info[type] : 'n/a' + }, + + download: function(release){ + var self = this; + + var release_el = self.releases_table.getElement('#release_'+release._id), + icon = release_el.getElement('.download.icon2'); + + if(icon) + icon.addClass('icon spinner').removeClass('download'); + + Api.request('release.manual_download', { + 'data': { + 'id': release._id + }, + 'onComplete': function(json){ + if(icon) + icon.removeClass('icon spinner'); + + if(json.success){ + if(icon) + icon.addClass('completed'); + release_el.getElement('.release_status').set('text', 'snatched'); + } + else + if(icon) + icon.addClass('attention').set('title', 'Something went wrong when downloading, please check logs.'); + } + }); + }, + + ignore: function(release){ + + Api.request('release.ignore', { + 'data': { + 'id': release._id + } + }) + + }, + + markMovieDone: function(){ + var self = this; + + Api.request('media.delete', { + 'data': { + 'id': self.show.get('_id'), + 'delete_from': 'wanted' + }, + 'onComplete': function(){ + var movie = $(self.show); + movie.set('tween', { + 'duration': 300, + 'onComplete': function(){ + self.show.destroy() + } + }); + movie.tween('height', 0); + } + }); + + }, + + tryNextRelease: function(){ + var self = this; + + Api.request('movie.searcher.try_next', { + 'data': { + 'media_id': self.show.get('_id') + } + }); + + } + +}); + +EA.Refresh = new Class({ + + Extends: EpisodeAction, + + create: function(){ + var self = this; + + self.el = new Element('a.refresh', { + 'title': 'Refresh the movie info and do a forced search', + 'events': { + 'click': self.doRefresh.bind(self) + } + }); + + }, + + doRefresh: function(e){ + var self = this; + (e).preventDefault(); + + Api.request('media.refresh', { + 'data': { + 'id': self.episode.get('_id') + } + }); + } + +}); \ No newline at end of file diff --git a/couchpotato/core/media/show/_base/static/episode.js b/couchpotato/core/media/show/_base/static/episode.js new file mode 100755 index 0000000..92e49cc --- /dev/null +++ b/couchpotato/core/media/show/_base/static/episode.js @@ -0,0 +1,128 @@ +var Episode = new Class({ + + Extends: BlockBase, + + action: {}, + + initialize: function(show, options, data){ + var self = this; + self.setOptions(options); + + self.show = show; + self.options = options; + self.data = data; + + self.profile = self.show.profile; + + self.el = new Element('div.item.episode').adopt( + self.detail = new Element('div.item.data') + ); + + self.create(); + }, + + create: function(){ + var self = this; + + self.detail.set('id', 'episode_'+self.data._id); + + self.detail.adopt( + new Element('span.episode', {'text': (self.data.info.number || 0)}), + new Element('span.name', {'text': self.getTitle()}), + new Element('span.firstaired', {'text': self.data.info.firstaired}), + + self.quality = new Element('span.quality', { + 'events': { + 'click': function(e){ + var releases = self.detail.getElement('.item-actions .releases'); + + if(releases.isVisible()) + releases.fireEvent('click', [e]) + } + } + }), + self.actions = new Element('div.item-actions') + ); + + // Add profile + if(self.profile.data) { + self.profile.getTypes().each(function(type){ + var q = self.addQuality(type.get('quality'), type.get('3d')); + + if((type.finish == true || type.get('finish')) && !q.hasClass('finish')){ + q.addClass('finish'); + q.set('title', q.get('title') + ' Will finish searching for this movie if this quality is found.') + } + }); + } + + // Add releases + self.updateReleases(); + + Object.each(self.options.actions, function(action, key){ + self.action[key.toLowerCase()] = action = new self.options.actions[key](self); + if(action.el) + self.actions.adopt(action) + }); + }, + + updateReleases: function(){ + var self = this; + if(!self.data.releases || self.data.releases.length == 0) return; + + self.data.releases.each(function(release){ + + var q = self.quality.getElement('.q_'+ release.quality+(release.is_3d ? '.is_3d' : ':not(.is_3d)')), + status = release.status; + + if(!q && (status == 'snatched' || status == 'seeding' || status == 'done')) + q = self.addQuality(release.quality, release.is_3d || false); + + if (q && !q.hasClass(status)){ + q.addClass(status); + q.set('title', (q.get('title') ? q.get('title') : '') + ' status: '+ status) + } + + }); + }, + + addQuality: function(quality, is_3d){ + var self = this, + q = Quality.getQuality(quality); + + return new Element('span', { + 'text': q.label + (is_3d ? ' 3D' : ''), + 'class': 'q_'+q.identifier + (is_3d ? ' is_3d' : ''), + 'title': '' + }).inject(self.quality); + }, + + getTitle: function(){ + var self = this; + + var title = ''; + + if(self.data.info.titles && self.data.info.titles.length > 0) { + title = self.data.info.titles[0]; + } else { + title = 'Episode ' + self.data.info.number; + } + + return title; + }, + + getIdentifier: function(){ + var self = this; + + try { + return self.get('identifiers').imdb; + } + catch (e){ } + + return self.get('imdb'); + }, + + get: function(attr){ + return this.data[attr] || this.data.info[attr] + } +}); \ No newline at end of file diff --git a/couchpotato/core/media/show/_base/static/list.js b/couchpotato/core/media/show/_base/static/list.js new file mode 100755 index 0000000..4177725 --- /dev/null +++ b/couchpotato/core/media/show/_base/static/list.js @@ -0,0 +1,636 @@ +var ShowList = new Class({ + + Implements: [Events, Options], + + options: { + navigation: true, + limit: 50, + load_more: true, + loader: true, + menu: [], + add_new: false, + force_view: false + }, + + movies: [], + movies_added: {}, + total_movies: 0, + letters: {}, + filter: null, + + initialize: function(options){ + var self = this; + self.setOptions(options); + + self.offset = 0; + self.filter = self.options.filter || { + 'starts_with': null, + 'search': null + }; + + self.el = new Element('div.shows').adopt( + self.title = self.options.title ? new Element('h2', { + 'text': self.options.title, + 'styles': {'display': 'none'} + }) : null, + self.description = self.options.description ? new Element('div.description', { + 'html': self.options.description, + 'styles': {'display': 'none'} + }) : null, + self.movie_list = new Element('div.list'), + self.load_more = self.options.load_more ? new Element('a.load_more', { + 'events': { + 'click': self.loadMore.bind(self) + } + }) : null + ); + + if($(window).getSize().x <= 480 && !self.options.force_view) + self.changeView('list'); + else + self.changeView(self.getSavedView() || self.options.view || 'details'); + + self.getMovies(); + + App.on('movie.added', self.movieAdded.bind(self)); + App.on('movie.deleted', self.movieDeleted.bind(self)) + }, + + movieDeleted: function(notification){ + var self = this; + + if(self.movies_added[notification.data._id]){ + self.movies.each(function(movie){ + if(movie.get('_id') == notification.data._id){ + movie.destroy(); + delete self.movies_added[notification.data._id]; + self.setCounter(self.counter_count-1); + self.total_movies--; + } + }) + } + + self.checkIfEmpty(); + }, + + movieAdded: function(notification){ + var self = this; + + self.fireEvent('movieAdded', notification); + if(self.options.add_new && !self.movies_added[notification.data._id] && notification.data.status == self.options.status){ + window.scroll(0,0); + self.createShow(notification.data, 'top'); + self.setCounter(self.counter_count+1); + + self.checkIfEmpty(); + } + }, + + create: function(){ + var self = this; + + // Create the alphabet nav + if(self.options.navigation) + self.createNavigation(); + + if(self.options.load_more) + self.scrollspy = new ScrollSpy({ + min: function(){ + var c = self.load_more.getCoordinates(); + return c.top - window.document.getSize().y - 300 + }, + onEnter: self.loadMore.bind(self) + }); + + self.created = true; + }, + + addMovies: function(movies, total){ + var self = this; + + if(!self.created) self.create(); + + // do scrollspy + if(movies.length < self.options.limit && self.scrollspy){ + self.load_more.hide(); + self.scrollspy.stop(); + } + + Object.each(movies, function(movie){ + self.createShow(movie); + }); + + self.total_movies += total; + self.setCounter(total); + + }, + + setCounter: function(count){ + var self = this; + + if(!self.navigation_counter) return; + + self.counter_count = count; + self.navigation_counter.set('text', (count || 0) + ' shows'); + + if (self.empty_message) { + self.empty_message.destroy(); + self.empty_message = null; + } + + if(self.total_movies && count == 0 && !self.empty_message){ + var message = (self.filter.search ? 'for "'+self.filter.search+'"' : '') + + (self.filter.starts_with ? ' in '+self.filter.starts_with+'' : ''); + + self.empty_message = new Element('.message', { + 'html': 'No shows found ' + message + '.
' + }).grab( + new Element('a', { + 'text': 'Reset filter', + 'events': { + 'click': function(){ + self.filter = { + 'starts_with': null, + 'search': null + }; + self.navigation_search_input.set('value', ''); + self.reset(); + self.activateLetter(); + self.getMovies(true); + self.last_search_value = ''; + } + } + }) + ).inject(self.movie_list); + + } + + }, + + createShow: function(show, inject_at){ + var self = this; + var m = new Show(self, { + 'actions': self.options.actions, + 'view': self.current_view, + 'onSelect': self.calculateSelected.bind(self) + }, show); + + $(m).inject(self.movie_list, inject_at || 'bottom'); + + m.fireEvent('injected'); + + self.movies.include(m); + self.movies_added[show._id] = true; + }, + + createNavigation: function(){ + var self = this; + var chars = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + + self.el.addClass('with_navigation'); + + self.navigation = new Element('div.alph_nav').adopt( + self.mass_edit_form = new Element('div.mass_edit_form').adopt( + new Element('span.select').adopt( + self.mass_edit_select = new Element('input[type=checkbox].inlay', { + 'events': { + 'change': self.massEditToggleAll.bind(self) + } + }), + self.mass_edit_selected = new Element('span.count', {'text': 0}), + self.mass_edit_selected_label = new Element('span', {'text': 'selected'}) + ), + new Element('div.quality').adopt( + self.mass_edit_quality = new Element('select'), + new Element('a.button.orange', { + 'text': 'Change quality', + 'events': { + 'click': self.changeQualitySelected.bind(self) + } + }) + ), + new Element('div.delete').adopt( + new Element('span[text=or]'), + new Element('a.button.red', { + 'text': 'Delete', + 'events': { + 'click': self.deleteSelected.bind(self) + } + }) + ), + new Element('div.refresh').adopt( + new Element('span[text=or]'), + new Element('a.button.green', { + 'text': 'Refresh', + 'events': { + 'click': self.refreshSelected.bind(self) + } + }) + ) + ), + new Element('div.menus').adopt( + self.navigation_counter = new Element('span.counter[title=Total]'), + self.filter_menu = new Block.Menu(self, { + 'class': 'filter' + }), + self.navigation_actions = new Element('ul.actions', { + 'events': { + 'click:relay(li)': function(e, el){ + var a = 'active'; + self.navigation_actions.getElements('.'+a).removeClass(a); + self.changeView(el.get('data-view')); + this.addClass(a); + + el.inject(el.getParent(), 'top'); + el.getSiblings().hide(); + setTimeout(function(){ + el.getSiblings().setStyle('display', null); + }, 100) + } + } + }), + self.navigation_menu = new Block.Menu(self, { + 'class': 'extra' + }) + ) + ).inject(self.el, 'top'); + + // Mass edit + self.mass_edit_select_class = new Form.Check(self.mass_edit_select); + Quality.getActiveProfiles().each(function(profile){ + new Element('option', { + 'value': profile.get('_id'), + 'text': profile.get('label') + }).inject(self.mass_edit_quality) + }); + + self.filter_menu.addLink( + self.navigation_search_input = new Element('input', { + 'title': 'Search through ' + self.options.identifier, + 'placeholder': 'Search through ' + self.options.identifier, + 'events': { + 'keyup': self.search.bind(self), + 'change': self.search.bind(self) + } + }) + ).addClass('search'); + + var available_chars; + self.filter_menu.addEvent('open', function(){ + self.navigation_search_input.focus(); + + // Get available chars and highlight + if(!available_chars && (self.navigation.isDisplayed() || self.navigation.isVisible())) + Api.request('media.available_chars', { + 'data': Object.merge({ + 'type': 'show', + 'status': self.options.status + }, self.filter), + 'onSuccess': function(json){ + available_chars = json.chars; + + available_chars.each(function(c){ + self.letters[c.capitalize()].addClass('available') + }) + + } + }); + }); + + self.filter_menu.addLink( + self.navigation_alpha = new Element('ul.numbers', { + 'events': { + 'click:relay(li.available)': function(e, el){ + self.activateLetter(el.get('data-letter')); + self.getMovies(true) + } + } + }) + ); + + // Actions + ['mass_edit', 'details', 'list'].each(function(view){ + var current = self.current_view == view; + new Element('li', { + 'class': 'icon2 ' + view + (current ? ' active ' : ''), + 'data-view': view + }).inject(self.navigation_actions, current ? 'top' : 'bottom'); + }); + + // All + self.letters['all'] = new Element('li.letter_all.available.active', { + 'text': 'ALL' + }).inject(self.navigation_alpha); + + // Chars + chars.split('').each(function(c){ + self.letters[c] = new Element('li', { + 'text': c, + 'class': 'letter_'+c, + 'data-letter': c + }).inject(self.navigation_alpha); + }); + + // Add menu or hide + if (self.options.menu.length > 0) + self.options.menu.each(function(menu_item){ + self.navigation_menu.addLink(menu_item); + }); + else + self.navigation_menu.hide(); + + }, + + calculateSelected: function(){ + var self = this; + + var selected = 0, + movies = self.movies.length; + self.movies.each(function(movie){ + selected += movie.isSelected() ? 1 : 0 + }); + + var indeterminate = selected > 0 && selected < movies, + checked = selected == movies && selected > 0; + + self.mass_edit_select.set('indeterminate', indeterminate); + + self.mass_edit_select_class[checked ? 'check' : 'uncheck'](); + self.mass_edit_select_class.element[indeterminate ? 'addClass' : 'removeClass']('indeterminate'); + + self.mass_edit_selected.set('text', selected); + }, + + deleteSelected: function(){ + var self = this, + ids = self.getSelectedMovies(), + help_msg = self.identifier == 'wanted' ? 'If you do, you won\'t be able to watch them, as they won\'t get downloaded!' : 'Your files will be safe, this will only delete the reference from the CouchPotato manage list'; + + var qObj = new Question('Are you sure you want to delete '+ids.length+' movie'+ (ids.length != 1 ? 's' : '') +'?', help_msg, [{ + 'text': 'Yes, delete '+(ids.length != 1 ? 'them' : 'it'), + 'class': 'delete', + 'events': { + 'click': function(e){ + (e).preventDefault(); + this.set('text', 'Deleting..'); + Api.request('media.delete', { + 'method': 'post', + 'data': { + 'id': ids.join(','), + 'delete_from': self.options.identifier + }, + 'onSuccess': function(){ + qObj.close(); + + var erase_movies = []; + self.movies.each(function(movie){ + if (movie.isSelected()){ + $(movie).destroy(); + erase_movies.include(movie); + } + }); + + erase_movies.each(function(movie){ + self.movies.erase(movie); + movie.destroy(); + self.setCounter(self.counter_count-1); + self.total_movies--; + }); + + self.calculateSelected(); + } + }); + + } + } + }, { + 'text': 'Cancel', + 'cancel': true + }]); + + }, + + changeQualitySelected: function(){ + var self = this; + var ids = self.getSelectedMovies(); + + Api.request('movie.edit', { + 'method': 'post', + 'data': { + 'id': ids.join(','), + 'profile_id': self.mass_edit_quality.get('value') + }, + 'onSuccess': self.search.bind(self) + }); + }, + + refreshSelected: function(){ + var self = this; + var ids = self.getSelectedMovies(); + + Api.request('media.refresh', { + 'method': 'post', + 'data': { + 'id': ids.join(',') + } + }); + }, + + getSelectedMovies: function(){ + var self = this; + + var ids = []; + self.movies.each(function(movie){ + if (movie.isSelected()) + ids.include(movie.get('_id')) + }); + + return ids + }, + + massEditToggleAll: function(){ + var self = this; + + var select = self.mass_edit_select.get('checked'); + + self.movies.each(function(movie){ + movie.select(select) + }); + + self.calculateSelected() + }, + + reset: function(){ + var self = this; + + self.movies = []; + if(self.mass_edit_select) + self.calculateSelected(); + if(self.navigation_alpha) + self.navigation_alpha.getElements('.active').removeClass('active'); + + self.offset = 0; + if(self.scrollspy){ + //self.load_more.show(); + self.scrollspy.start(); + } + }, + + activateLetter: function(letter){ + var self = this; + + self.reset(); + + self.letters[letter || 'all'].addClass('active'); + self.filter.starts_with = letter; + + }, + + changeView: function(new_view){ + var self = this; + + self.el + .removeClass(self.current_view+'_list') + .addClass(new_view+'_list'); + + self.current_view = new_view; + Cookie.write(self.options.identifier+'_view2', new_view, {duration: 1000}); + }, + + getSavedView: function(){ + var self = this; + return Cookie.read(self.options.identifier+'_view2'); + }, + + search: function(){ + var self = this; + + if(self.search_timer) clearTimeout(self.search_timer); + self.search_timer = (function(){ + var search_value = self.navigation_search_input.get('value'); + if (search_value == self.last_search_value) return; + + self.reset(); + + self.activateLetter(); + self.filter.search = search_value; + + self.getMovies(true); + + self.last_search_value = search_value; + + }).delay(250); + + }, + + update: function(){ + var self = this; + + self.reset(); + self.getMovies(true); + }, + + getMovies: function(reset){ + var self = this; + + if(self.scrollspy){ + self.scrollspy.stop(); + self.load_more.set('text', 'loading...'); + } + + if(self.movies.length == 0 && self.options.loader){ + + self.loader_first = new Element('div.loading').adopt( + new Element('div.message', {'text': self.options.title ? 'Loading \'' + self.options.title + '\'' : 'Loading...'}) + ).inject(self.el, 'top'); + + createSpinner(self.loader_first, { + radius: 4, + length: 4, + width: 1 + }); + + self.el.setStyle('min-height', 93); + + } + + Api.request(self.options.api_call || 'media.list', { + 'data': Object.merge({ + 'type': self.options.type || 'movie', + 'status': self.options.status, + 'limit_offset': self.options.limit ? self.options.limit + ',' + self.offset : null + }, self.filter), + 'onSuccess': function(json){ + + if(reset) + self.movie_list.empty(); + + if(self.loader_first){ + var lf = self.loader_first; + self.loader_first.addClass('hide'); + self.loader_first = null; + setTimeout(function(){ + lf.destroy(); + }, 20000); + self.el.setStyle('min-height', null); + } + + self.store(json.shows); + self.addMovies(json.shows, json.total || json.shows.length); + if(self.scrollspy) { + self.load_more.set('text', 'load more movies'); + self.scrollspy.start(); + } + + self.checkIfEmpty(); + self.fireEvent('loaded'); + } + }); + }, + + loadMore: function(){ + var self = this; + if(self.offset >= self.options.limit) + self.getMovies() + }, + + store: function(movies){ + var self = this; + + self.offset += movies.length; + + }, + + checkIfEmpty: function(){ + var self = this; + + var is_empty = self.movies.length == 0 && (self.total_movies == 0 || self.total_movies === undefined); + + if(self.title) + self.title[is_empty ? 'hide' : 'show'](); + + if(self.description) + self.description.setStyle('display', [is_empty ? 'none' : '']); + + if(is_empty && self.options.on_empty_element){ + self.options.on_empty_element.inject(self.loader_first || self.title || self.movie_list, 'after'); + + if(self.navigation) + self.navigation.hide(); + + self.empty_element = self.options.on_empty_element; + } + else if(self.empty_element){ + self.empty_element.destroy(); + + if(self.navigation) + self.navigation.show(); + } + + }, + + toElement: function(){ + return this.el; + } + +}); diff --git a/couchpotato/core/media/show/_base/static/search.js b/couchpotato/core/media/show/_base/static/search.js old mode 100644 new mode 100755 index 91e2ed3..96498e5 --- a/couchpotato/core/media/show/_base/static/search.js +++ b/couchpotato/core/media/show/_base/static/search.js @@ -185,7 +185,7 @@ Block.Search.ShowItem = new Class({ self.category_select.show(); categories.each(function(category){ new Element('option', { - 'value': category.data.id, + 'value': category.data._id, 'text': category.data.label }).inject(self.category_select); }); @@ -198,8 +198,8 @@ Block.Search.ShowItem = new Class({ profiles.each(function(profile){ new Element('option', { - 'value': profile.id ? profile.id : profile.data.id, - 'text': profile.label ? profile.label : profile.data.label + 'value': profile.get('_id'), + 'text': profile.get('label') }).inject(self.profile_select) }); diff --git a/couchpotato/core/media/show/_base/static/season.js b/couchpotato/core/media/show/_base/static/season.js new file mode 100755 index 0000000..0f1cf61 --- /dev/null +++ b/couchpotato/core/media/show/_base/static/season.js @@ -0,0 +1,127 @@ +var Season = new Class({ + + Extends: BlockBase, + + action: {}, + + initialize: function(show, options, data){ + var self = this; + self.setOptions(options); + + self.show = show; + self.options = options; + self.data = data; + + self.profile = self.show.profile; + + self.el = new Element('div.item.season').adopt( + self.detail = new Element('div.item.data') + ); + + self.create(); + }, + + create: function(){ + var self = this; + + self.detail.set('id', 'season_'+self.data._id); + + self.detail.adopt( + new Element('span.name', {'text': self.getTitle()}), + + self.quality = new Element('span.quality', { + 'events': { + 'click': function(e){ + var releases = self.detail.getElement('.item-actions .releases'); + + if(releases.isVisible()) + releases.fireEvent('click', [e]) + } + } + }), + self.actions = new Element('div.item-actions') + ); + + // Add profile + if(self.profile.data) { + self.profile.getTypes().each(function(type){ + var q = self.addQuality(type.get('quality'), type.get('3d')); + + if((type.finish == true || type.get('finish')) && !q.hasClass('finish')){ + q.addClass('finish'); + q.set('title', q.get('title') + ' Will finish searching for this movie if this quality is found.') + } + }); + } + + // Add releases + self.updateReleases(); + + Object.each(self.options.actions, function(action, key){ + self.action[key.toLowerCase()] = action = new self.options.actions[key](self); + if(action.el) + self.actions.adopt(action) + }); + }, + + updateReleases: function(){ + var self = this; + if(!self.data.releases || self.data.releases.length == 0) return; + + self.data.releases.each(function(release){ + + var q = self.quality.getElement('.q_'+ release.quality+(release.is_3d ? '.is_3d' : ':not(.is_3d)')), + status = release.status; + + if(!q && (status == 'snatched' || status == 'seeding' || status == 'done')) + q = self.addQuality(release.quality, release.is_3d || false); + + if (q && !q.hasClass(status)){ + q.addClass(status); + q.set('title', (q.get('title') ? q.get('title') : '') + ' status: '+ status) + } + + }); + }, + + addQuality: function(quality, is_3d){ + var self = this, + q = Quality.getQuality(quality); + + return new Element('span', { + 'text': q.label + (is_3d ? ' 3D' : ''), + 'class': 'q_'+q.identifier + (is_3d ? ' is_3d' : ''), + 'title': '' + }).inject(self.quality); + }, + + getTitle: function(){ + var self = this; + + var title = ''; + + if(self.data.info.number) { + title = 'Season ' + self.data.info.number; + } else { + // Season 0 / Specials + title = 'Specials'; + } + + return title; + }, + + getIdentifier: function(){ + var self = this; + + try { + return self.get('identifiers').imdb; + } + catch (e){ } + + return self.get('imdb'); + }, + + get: function(attr){ + return this.data[attr] || this.data.info[attr] + } +}); \ No newline at end of file diff --git a/couchpotato/core/media/show/_base/static/show.css b/couchpotato/core/media/show/_base/static/show.css new file mode 100755 index 0000000..ec20240 --- /dev/null +++ b/couchpotato/core/media/show/_base/static/show.css @@ -0,0 +1,1215 @@ +.shows.details_list { + padding: 10px 0 20px; + position: relative; + z-index: 3; + width: 100%; +} + + .shows > div { + clear: both; + } + + .shows > div .message { + display: block; + padding: 20px; + font-size: 20px; + color: white; + text-align: center; + } + .shows > div .message a { + padding: 20px; + display: block; + } + + .shows.thumbs_list > div:not(.description) { + margin-right: -4px; + } + + .shows .loading { + display: block; + padding: 20px 0 0 0; + width: 100%; + z-index: 3; + transition: all .4s cubic-bezier(0.9,0,0.1,1); + height: 40px; + opacity: 1; + position: absolute; + text-align: center; + } + .shows .loading.hide { + height: 0; + padding: 0; + opacity: 0; + margin-top: -20px; + overflow: hidden; + } + + .shows .loading .spinner { + display: inline-block; + } + + .shows .loading .message { + margin: 0 20px; + } + + .shows h2 { + margin-bottom: 20px; + } + + @media all and (max-width: 480px) { + .shows h2 { + font-size: 25px; + margin-bottom: 10px; + } + } + + .shows > .description { + position: absolute; + top: 30px; + right: 0; + font-style: italic; + opacity: 0.8; + } + .shows:hover > .description { + opacity: 1; + } + + @media all and (max-width: 860px) { + .shows > .description { + display: none; + } + } + + .shows.thumbs_list { + padding: 20px 0 20px; + } + + .home .shows { + padding-top: 6px; + } + + .shows .list .show { + position: relative; + margin: 10px 0; + padding-left: 20px; + overflow: hidden; + width: 100%; + height: 180px; + transition: all 0.6s cubic-bezier(0.9,0,0.1,1); + transition-property: width, height; + background: rgba(0,0,0,.2); + } + + .shows .list .show.expanded { + height: 360px; + } + + .shows .list .show .table.expanded { + height: 360px; + } + + .shows.mass_edit_list .show { + padding-left: 22px; + background: none; + } + + .shows.details_list .list .show { + padding-left: 120px; + } + + .shows.list_list .show:not(.details_view), + .shows.mass_edit_list .show { + height: 30px; + border-bottom: 1px solid rgba(255,255,255,.15); + } + + .shows.list_list .show:last-child, + .shows.mass_edit_list .show:last-child { + border: none; + } + + .shows.thumbs_list .show { + width: 16.66667%; + height: auto; + min-height: 200px; + display: inline-block; + margin: 0; + padding: 0; + vertical-align: top; + line-height: 0; + } + + @media all and (max-width: 800px) { + .shows.thumbs_list .show { + width: 25%; + min-height: 100px; + } + } + + .shows .list .show .mask { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + } + + .shows.list_list .show:not(.details_view), + .shows.mass_edit_list .show { + margin: 0; + } + + .shows .data { + padding: 20px; + height: 100%; + width: 100%; + position: relative; + transition: all .6s cubic-bezier(0.9,0,0.1,1); + right: 0; + } + .shows.list_list .show:not(.details_view) .data, + .shows.mass_edit_list .show .data { + padding: 0 0 0 10px; + border: 0; + background: #4e5969; + } + .shows.mass_edit_list .show .data { + padding-left: 8px; + } + + .shows.thumbs_list .data { + position: absolute; + left: 0; + top: 0; + width: 100%; + padding: 10px; + height: 100%; + background: none; + transition: none; + } + + .shows.thumbs_list .show:hover .data { + background: rgba(0,0,0,0.9); + } + + .shows .data.hide_right { + right: -100%; + } + + .shows .list .show .check { + display: none; + } + + .shows.mass_edit_list .show .check { + position: absolute; + left: 0; + top: 0; + display: block; + margin: 7px 0 0 5px; + } + + .shows .poster { + position: absolute; + left: 0; + width: 120px; + height: 180px; + line-height: 0; + overflow: hidden; + transition: all .6s cubic-bezier(0.9,0,0.1,1); + background: rgba(0,0,0,.1); + } + .shows.thumbs_list .poster { + position: relative; + } + .shows.list_list .show:not(.details_view) .poster, + .shows.mass_edit_list .poster { + width: 20px; + height: 30px; + } + .shows.mass_edit_list .poster { + display: none; + } + + .shows.thumbs_list .poster { + width: 100%; + height: 100%; + transition: none; + background: no-repeat center; + background-size: cover; + } + .shows.thumbs_list .no_thumbnail .empty_file { + width: 100%; + height: 100%; + } + + .shows .poster img, + .options .poster img { + width: 100%; + height: 100%; + } + .shows.thumbs_list .poster img { + height: auto; + width: 100%; + top: 0; + bottom: 0; + opacity: 0; + } + + .shows .info { + position: relative; + height: 100%; + width: 100%; + } + + .shows .info .title { + font-size: 28px; + font-weight: bold; + margin-bottom: 10px; + margin-top: 2px; + width: 100%; + padding-right: 80px; + transition: all 0.2s linear; + height: 35px; + top: -5px; + position: relative; + } + .shows.list_list .info .title, + .shows.mass_edit_list .info .title { + height: 100%; + top: 0; + margin: 0; + } + .touch_enabled .shows.list_list .info .title { + display: inline-block; + padding-right: 55px; + } + + .shows .info .title a { + display: inline-block; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; + height: 100%; + line-height: 30px; + color: rgb(255, 255, 255); + } + + .shows .info .title a:hover { + color: rgba(255, 255, 255, 0.6); + } + + .shows.thumbs_list .info .title a { + white-space: normal; + overflow: auto; + height: auto; + text-align: left; + } + + @media all and (max-width: 480px) { + .shows.thumbs_list .show .info .title a, + .shows.thumbs_list .show .info .year { + font-size: 15px; + line-height: 15px; + overflow: hidden; + } + } + + .shows.list_list .show:not(.details_view) .info .title, + .shows.mass_edit_list .info .title { + font-size: 16px; + font-weight: normal; + width: auto; + } + + .shows.thumbs_list .show:not(.no_thumbnail) .info { + display: none; + } + .shows.thumbs_list .show:hover .info { + display: block; + } + + .shows.thumbs_list .info .title { + font-size: 21px; + word-wrap: break-word; + padding: 0; + height: 100%; + } + + .shows .info .year { + position: absolute; + color: #bbb; + right: 0; + top: 6px; + text-align: right; + transition: all 0.2s linear; + font-weight: normal; + } + .shows.list_list .show:not(.details_view) .info .year, + .shows.mass_edit_list .info .year { + font-size: 1.25em; + right: 10px; + } + + .shows.thumbs_list .info .year { + font-size: 23px; + margin: 0; + bottom: 0; + left: 0; + top: auto; + right: auto; + color: #FFF; + line-height: 18px; + } + + .touch_enabled .shows.list_list .show .info .year { + font-size: 1em; + } + + .shows .info .description { + top: 30px; + clear: both; + bottom: 30px; + position: absolute; + } + .shows.list_list .show:not(.details_view) .info .description, + .shows.mass_edit_list .info .description, + .shows.thumbs_list .info .description { + display: none; + } + + .shows .data .eta { + display: none; + } + + .shows.details_list .data .eta { + position: absolute; + bottom: 0; + right: 0; + display: block; + min-height: 20px; + text-align: right; + font-style: italic; + opacity: .8; + font-size: 11px; + } + + .shows.details_list .movie:hover .data .eta { + display: none; + } + + .shows.thumbs_list .data .eta { + display: block; + position: absolute; + bottom: 40px; + } + + .shows .data .quality { + position: absolute; + bottom: 2px; + display: block; + min-height: 20px; + } + + .shows.list_list .show:hover .data .quality { + display: none; + } + + .touch_enabled .shows.list_list .show .data .quality { + position: relative; + display: inline-block; + margin: 0; + top: -4px; + } + + @media all and (max-width: 480px) { + .shows .data .quality { + display: none; + } + } + + .shows .status_suggest .data .quality, + .shows.thumbs_list .data .quality { + display: none; + } + + .shows .data .quality span { + padding: 2px 3px; + opacity: 0.5; + font-size: 10px; + height: 16px; + line-height: 12px; + vertical-align: middle; + display: inline-block; + text-transform: uppercase; + font-weight: normal; + margin: 0 4px 0 0; + border-radius: 2px; + background-color: rgba(255,255,255,0.1); + } + .shows.list_list .data .quality, + .shows.mass_edit_list .data .quality { + text-align: right; + right: 0; + margin-right: 60px; + z-index: 1; + top: 5px; + } + + .shows .data .quality .available, + .shows .data .quality .snatched, + .shows .data .quality .seeding { + opacity: 1; + cursor: pointer; + } + + .shows .data .quality .available { background-color: #578bc3; } + .shows .data .quality .failed, + .shows .data .quality .missing, + .shows .data .quality .ignored { background-color: #a43d34; } + .shows .data .quality .snatched { background-color: #a2a232; } + .shows .data .quality .done { + background-color: #369545; + opacity: 1; + } + .shows .data .quality .seeding { background-color: #0a6819; } + .shows .data .quality .finish { + background-image: url('../../images/sprite.png'); + background-repeat: no-repeat; + background-position: 0 2px; + padding-left: 14px; + background-size: 14px + } + + .shows .data .actions { + position: absolute; + bottom: 17px; + right: 20px; + line-height: 0; + top: 0; + width: auto; + opacity: 0; + display: none; + } + @media all and (max-width: 480px) { + .shows .data .actions { + display: none !important; + } + } + + .shows .list .show:hover .data .actions, + .touch_enabled .shows .list .show .data .actions { + opacity: 1; + display: inline-block; + } + + .shows.details_list .data .actions { + top: auto; + bottom: 18px; + } + + .shows .list .show:hover .actions { + opacity: 1; + display: inline-block; + } + .shows.thumbs_list .data .actions { + bottom: 12px; + right: 10px; + top: auto; + } + + .shows .list .show:hover .action { opacity: 0.6; } + .shows .list .show:hover .action:hover { opacity: 1; } + + .shows .data .action { + display: inline-block; + height: 22px; + min-width: 33px; + padding: 0 5px; + line-height: 26px; + text-align: center; + font-size: 13px; + color: #FFF; + margin-left: 1px; + } + .shows .data .action.trailer { color: #FFF; } + .shows .data .action.download { color: #b9dec0; } + .shows .data .action.edit { color: #c6b589; } + .shows .data .action.refresh { color: #cbeecc; } + .shows .data .action.delete { color: #e9b0b0; } + .shows .data .action.directory { color: #ffed92; } + .shows .data .action.readd { color: #c2fac5; } + + .shows.mass_edit_list .show .data .actions { + display: none; + } + + .shows.list_list .show:not(.details_view):hover .actions, + .shows.mass_edit_list .show:hover .actions, + .touch_enabled .shows.list_list .show:not(.details_view) .actions { + margin: 0; + background: #4e5969; + top: 2px; + bottom: 2px; + right: 5px; + z-index: 3; + } + + .shows .delete_container { + clear: both; + text-align: center; + font-size: 20px; + position: absolute; + padding: 80px 0 0; + left: 120px; + right: 0; + } + .shows .delete_container .or { + padding: 10px; + } + .shows .delete_container .delete { + background-color: #ff321c; + font-weight: normal; + } + .shows .delete_container .delete:hover { + color: #fff; + background-color: #d32917; + } + + .shows .options { + position: absolute; + right: 0; + left: 120px; + } + + .shows .options .form { + margin: 80px 0 0; + font-size: 20px; + text-align: center; + } + + .shows .options .form select { + margin-right: 20px; + } + + .shows .options .table { + height: 180px; + overflow: auto; + line-height: 2em; + } + .shows .options .table .item { + border-bottom: 1px solid rgba(255,255,255,0.1); + } + .shows .options .table .item.ignored span, + .shows .options .table .item.failed span { + text-decoration: line-through; + color: rgba(255,255,255,0.4); + } + .shows .options .table .item.ignored .delete:before, + .shows .options .table .item.failed .delete:before { + display: inline-block; + content: "\e04b"; + transform: scale(-1, 1); + } + + .shows .options .table .item:last-child { border: 0; } + .shows .options .table .item:nth-child(even) { + background: rgba(255,255,255,0.05); + } + .shows .options .table .item:not(.head):not(.data):hover { + background: rgba(255,255,255,0.03); + } + + .shows .options .table .item > * { + display: inline-block; + padding: 0 5px; + width: 60px; + min-height: 24px; + white-space: nowrap; + text-overflow: ellipsis; + text-align: center; + vertical-align: top; + border-left: 1px solid rgba(255, 255, 255, 0.1); + } + .shows .options .table .item > *:first-child { + border: 0; + } + .shows .options .table .provider { + width: 120px; + text-overflow: ellipsis; + overflow: hidden; + } + .shows .options .table .name { + width: 340px; + overflow: hidden; + text-align: left; + padding: 0 10px; + } + .shows .options .table.files .name { width: 590px; } + .shows .options .table .type { width: 130px; } + .shows .options .table .is_available { width: 90px; } + .shows .options .table .age, + .shows .options .table .size { width: 40px; } + + .shows .options .table a { + width: 30px !important; + height: 20px; + opacity: 0.8; + line-height: 25px; + } + .shows .options .table a:hover { opacity: 1; } + .shows .options .table a.download { color: #a7fbaf; } + .shows .options .table a.delete { color: #fda3a3; } + .shows .options .table .ignored a.delete, + .shows .options .table .failed a.delete { color: #b5fda3; } + + .shows .options .table .head > * { + font-weight: bold; + font-size: 14px; + padding-top: 4px; + padding-bottom: 4px; + height: auto; + } + + .trailer_container { + width: 100%; + background: #000; + text-align: center; + transition: all .6s cubic-bezier(0.9,0,0.1,1); + overflow: hidden; + left: 0; + position: absolute; + z-index: 10; + } + @media only screen and (device-width: 768px) { + .trailer_container iframe { + margin-top: 25px; + } + } + + .trailer_container.hide { + height: 0 !important; + } + + .hide_trailer { + position: absolute; + top: 0; + left: 50%; + margin-left: -50px; + width: 100px; + text-align: center; + padding: 3px 10px; + background: #4e5969; + transition: all .2s cubic-bezier(0.9,0,0.1,1) .2s; + z-index: 11; + } + .hide_trailer.hide { + top: -30px; + } + .shows .list .episodes .item { + position: relative; + width: 100%; + height: auto; + padding: 0; + + text-align: left; + + transition: all .6s cubic-bezier(0.9,0,0.1,1); + } + + .shows .list .episodes .item.data { + background: none; + } + + .shows .list .episodes .item.data span.episode { + width: 40px; + padding: 0 10px; + } + + .shows .list .episodes .item.data span.name { + width: 280px; + } + + .shows .list .episodes .item.data span.firstaired { + width: 80px; + } + + .shows .list .episodes .item.data span.quality { + bottom: auto; + } + + .shows .list .episodes .item.season:hover { + background: none !important; + } + + .shows .list .episodes .item.season .data { + padding-top: 4px; + padding-bottom: 4px; + height: auto; + + font-weight: bold; + font-size: 14px; + } + + .shows .list .episodes .item.season .data span.name { + width: 400px; + } + + .shows .list .episodes .item.season .data span.quality { + opacity: 0.6; + } + + .shows .list .episodes .item.season:hover .data span.quality { + opacity: 1; + } + + .shows .list .episodes .episode-options { + display: block; + + width: 100%; + height: 0; + min-height: 0; + + padding: 0; + + transition: all 0.6s cubic-bezier(0.9,0,0.1,1); + transition-property: width, height; + + overflow: hidden; + } + + .shows .list .episodes .releases.table { + width: 100%; + height: auto; + padding: 0; + + background: rgba(0,0,0,.2); + } + + .shows .list .episodes .releases.table span.name { + width: 300px; + } + + .shows .list .episodes .releases.table span.status { + width: 85px; + } + + .shows .list .episodes .item-actions { + position: absolute; + width: auto; + right: 0; + + display: none; + opacity: 0; + + border-left: none; + } + + .shows .list .episodes .item:hover .item-actions { + display: inline-block; + opacity: 1; + } + + .shows .list .episodes .item:hover .item-action { + opacity: 0.6; + } + + .shows .list .episodes .item .item-action { + display: inline-block; + height: 22px; + min-width: 33px; + + line-height: 26px; + text-align: center; + font-size: 13px; + + margin-left: 1px; + padding: 0 5px; + } + + .shows .list .episodes .item .item-action:hover { + opacity: 1; + } + + .shows .list .show .episodes .refresh { + color: #cbeecc; + } + + .shows .list .show .try_container { + padding: 5px 10px; + text-align: center; + } + + .shows .list .show .try_container a { + margin: 0 5px; + padding: 2px 5px; + } + + .shows .list .show .releases .next_release { + border-left: 6px solid #2aa300; + } + + .shows .list .show .releases .next_release > :first-child { + margin-left: -6px; + } + + .shows .list .show .releases .last_release { + border-left: 6px solid #ffa200; + } + + .shows .list .show .releases .last_release > :first-child { + margin-left: -6px; + } + .shows .list .show .trynext { + display: inline; + position: absolute; + right: 180px; + z-index: 2; + opacity: 0; + background: #4e5969; + text-align: right; + height: 100%; + top: 0; + } + .touch_enabled .shows .list .show .trynext { + display: none; + } + + @media all and (max-width: 480px) { + .shows .list .show .trynext { + display: none; + } + } + .shows.mass_edit_list .trynext { display: none; } + .wanted .shows .list .show .trynext { + padding-right: 30px; + } + .shows .list .show:hover .trynext, + .touch_enabled .shows.details_list .list .show .trynext { + opacity: 1; + } + + .shows.details_list .list .show .trynext { + background: #47515f; + padding: 0; + right: 0; + height: 25px; + } + + .shows .list .show .trynext a { + background-position: 5px center; + padding: 0 5px 0 25px; + margin-right: 10px; + color: #FFF; + height: 100%; + line-height: 27px; + font-family: OpenSans, "Helvetica Neue", Helvetica, Arial, Geneva, sans-serif; + } + .shows .list .show .trynext a:before { + margin: 2px 0 0 -20px; + position: absolute; + font-family: 'Elusive-Icons'; + } + .shows.details_list .list .show .trynext a { + line-height: 23px; + } + .shows .list .show .trynext a:last-child { + margin: 0; + } + .shows .list .show .trynext a:hover, + .touch_enabled .shows .list .show .trynext a { + background-color: #369545; + } + + .shows .load_more { + display: block; + padding: 10px; + text-align: center; + font-size: 20px; + } + .shows .load_more.loading { + opacity: .5; + } + +.shows .alph_nav { + height: 44px; +} + + @media all and (max-width: 480px) { + .shows .alph_nav { + display: none; + } + } + + .shows .alph_nav .menus { + display: inline-block; + float: right; + } + +.shows .alph_nav .numbers, +.shows .alph_nav .counter, +.shows .alph_nav .actions { + list-style: none; + padding: 0 0 1px; + margin: 0; + user-select: none; +} + + .shows .alph_nav .counter { + display: inline-block; + text-align: right; + padding: 0 10px; + height: 100%; + line-height: 43px; + border-right: 1px solid rgba(255,255,255,.07); + } + + .shows .alph_nav .numbers li, + .shows .alph_nav .actions li { + display: inline-block; + vertical-align: top; + height: 100%; + line-height: 30px; + text-align: center; + border: 1px solid transparent; + transition: all 0.1s ease-in-out; + } + + .shows .alph_nav .numbers li { + width: 30px; + height: 30px; + opacity: 0.3; + } + .shows .alph_nav .numbers li.letter_all { + width: 60px; + } + + .shows .alph_nav li.available { + font-weight: bold; + cursor: pointer; + opacity: 1; + + } + .shows .alph_nav li.active.available, + .shows .alph_nav li.available:hover { + background: rgba(0,0,0,.1); + } + + .shows .alph_nav .search input { + width: 100%; + height: 44px; + display: inline-block; + border: 0; + background: none; + color: #444; + font-size: 14px; + padding: 0 10px 0 30px; + border-bottom: 1px solid rgba(0,0,0,.08); + } + .shows .alph_nav .search input:focus { + background: rgba(0,0,0,.08); + } + + .shows .alph_nav .search input::-webkit-input-placeholder { + color: #444; + opacity: .6; + } + + .shows .alph_nav .search:before { + font-family: 'Elusive-Icons'; + content: "\e03e"; + position: absolute; + height: 20px; + line-height: 45px; + font-size: 12px; + margin: 0 0 0 10px; + opacity: .6; + color: #444; + } + + .shows .alph_nav .actions { + -moz-user-select: none; + width: 44px; + height: 44px; + display: inline-block; + vertical-align: top; + z-index: 200; + position: relative; + border: 1px solid rgba(255,255,255,.07); + border-width: 0 1px; + } + .shows .alph_nav .actions:hover { + box-shadow: 0 100px 20px -10px rgba(0,0,0,0.55); + } + .shows .alph_nav .actions li { + width: 100%; + height: 45px; + line-height: 40px; + position: relative; + z-index: 20; + display: none; + cursor: pointer; + } + .shows .alph_nav .actions:hover li:not(.active) { + display: block; + background: #FFF; + color: #444; + } + .shows .alph_nav .actions li:hover:not(.active) { + background: #ccc; + } + .shows .alph_nav .actions li.active { + display: block; + } + + .shows .alph_nav .actions li.mass_edit:before { + content: "\e070"; + } + + .shows .alph_nav .actions li.list:before { + content: "\e0d8"; + } + + .shows .alph_nav .actions li.details:before { + content: "\e022"; + } + + .shows .alph_nav .mass_edit_form { + clear: both; + text-align: center; + display: none; + overflow: hidden; + float: left; + height: 44px; + line-height: 44px; + } + .shows.mass_edit_list .mass_edit_form { + display: inline-block; + } + .shows.mass_edit_list .mass_edit_form .select { + font-size: 14px; + display: inline-block; + } + .shows.mass_edit_list .mass_edit_form .select .check { + display: inline-block; + vertical-align: middle; + margin: -4px 0 0 5px; + } + .shows.mass_edit_list .mass_edit_form .select span { + opacity: 0.7; + } + .shows.mass_edit_list .mass_edit_form .select .count { + font-weight: bold; + margin: 0 3px 0 10px; + } + + .shows .alph_nav .mass_edit_form .quality { + display: inline-block; + margin: 0 0 0 16px; + } + .shows .alph_nav .mass_edit_form .quality select { + width: 120px; + margin-right: 5px; + } + .shows .alph_nav .mass_edit_form .button { + padding: 3px 7px; + } + + .shows .alph_nav .mass_edit_form .refresh, + .shows .alph_nav .mass_edit_form .delete { + display: inline-block; + margin-left: 8px; + } + + .shows .alph_nav .mass_edit_form .refresh span, + .shows .alph_nav .mass_edit_form .delete span { + margin: 0 10px 0 0; + } + + .shows .alph_nav .more_menu > a { + background: none; + } + + .shows .alph_nav .more_menu.extra > a:before { + content: '...'; + font-size: 1.7em; + line-height: 23px; + text-align: center; + display: block; + } + + .shows .alph_nav .more_menu.filter { + } + + .shows .alph_nav .more_menu.filter > a:before { + content: "\e0e8"; + font-family: 'Elusive-Icons'; + line-height: 33px; + display: block; + text-align: center; + } + + .shows .alph_nav .more_menu.filter .wrapper { + right: 88px; + width: 300px; + } + +.shows .empty_wanted { + background-image: url('../../images/emptylist.png'); + background-position: 80% 0; + height: 750px; + width: 100%; + max-width: 900px; + padding-top: 260px; +} + +.shows .empty_manage { + text-align: center; + font-size: 25px; + line-height: 150%; + padding: 40px 0; +} + + .shows .empty_manage .after_manage { + margin-top: 30px; + font-size: 16px; + } + + .shows .progress { + padding: 10px; + margin: 5px 0; + text-align: left; + } + + .shows .progress > div { + padding: 5px 10px; + font-size: 12px; + line-height: 12px; + text-align: left; + display: inline-block; + width: 49%; + background: rgba(255, 255, 255, 0.05); + margin: 2px 0.5%; + } + + .shows .progress > div .folder { + display: inline-block; + padding: 5px 20px 5px 0; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + width: 85%; + direction: ltr; + vertical-align: middle; + } + + .shows .progress > div .percentage { + display: inline-block; + text-transform: uppercase; + font-weight: normal; + font-size: 20px; + border-left: 1px solid rgba(255, 255, 255, .2); + width: 15%; + text-align: right; + vertical-align: middle; + } diff --git a/couchpotato/core/media/show/_base/static/show.episodes.js b/couchpotato/core/media/show/_base/static/show.episodes.js new file mode 100755 index 0000000..c622e74 --- /dev/null +++ b/couchpotato/core/media/show/_base/static/show.episodes.js @@ -0,0 +1,92 @@ +var Episodes = new Class({ + initialize: function(show, options) { + var self = this; + + self.show = show; + self.options = options; + }, + + open: function(){ + var self = this; + + if(!self.container){ + self.container = new Element('div.options').grab( + self.episodes_container = new Element('div.episodes.table') + ); + + self.container.inject(self.show, 'top'); + + Api.request('library.tree', { + 'data': { + 'media_id': self.show.data._id + }, + 'onComplete': function(json){ + self.data = json.result; + + self.createEpisodes(); + } + }); + } + + self.show.slide('in', self.container, true); + }, + + createEpisodes: function() { + var self = this; + + self.data.seasons.sort(self.sortSeasons); + self.data.seasons.each(function(season) { + self.createSeason(season); + + season.episodes.sort(self.sortEpisodes); + season.episodes.each(function(episode) { + self.createEpisode(episode); + }); + }); + }, + + createSeason: function(season) { + var self = this, + s = new Season(self.show, self.options, season); + + $(s).inject(self.episodes_container); + }, + + createEpisode: function(episode){ + var self = this, + e = new Episode(self.show, self.options, episode); + + $(e).inject(self.episodes_container); + }, + + sortSeasons: function(a, b) { + // Move "Specials" to the bottom of the list + if(!a.info.number) { + return 1; + } + + if(!b.info.number) { + return -1; + } + + // Order seasons descending + if(a.info.number < b.info.number) + return -1; + + if(a.info.number > b.info.number) + return 1; + + return 0; + }, + + sortEpisodes: function(a, b) { + // Order episodes descending + if(a.info.number < b.info.number) + return -1; + + if(a.info.number > b.info.number) + return 1; + + return 0; + } +}); \ No newline at end of file diff --git a/couchpotato/core/media/show/_base/static/show.js b/couchpotato/core/media/show/_base/static/show.js new file mode 100755 index 0000000..6019721 --- /dev/null +++ b/couchpotato/core/media/show/_base/static/show.js @@ -0,0 +1,370 @@ +var Show = new Class({ + + Extends: BlockBase, + + action: {}, + + initialize: function(list, options, data){ + var self = this; + + self.data = data; + self.view = options.view || 'details'; + self.list = list; + + self.el = new Element('div.show'); + + self.episodes = new Episodes(self, { + 'actions': [EA.IMDB, EA.Release, EA.Refresh] + }); + + self.profile = Quality.getProfile(data.profile_id) || {}; + self.category = CategoryList.getCategory(data.category_id) || {}; + self.parent(self, options); + + self.addEvents(); + }, + + addEvents: function(){ + var self = this; + + self.global_events = {}; + + // Do refresh with new data + self.global_events['movie.update'] = function(notification){ + if(self.data._id != notification.data._id) return; + + self.busy(false); + self.removeView(); + self.update.delay(2000, self, notification); + }; + App.on('movie.update', self.global_events['movie.update']); + + // Add spinner on load / search + ['media.busy', 'movie.searcher.started'].each(function(listener){ + self.global_events[listener] = function(notification){ + if(notification.data && (self.data._id == notification.data._id || (typeOf(notification.data._id) == 'array' && notification.data._id.indexOf(self.data._id) > -1))) + self.busy(true); + }; + App.on(listener, self.global_events[listener]); + }); + + // Remove spinner + self.global_events['movie.searcher.ended'] = function(notification){ + if(notification.data && self.data._id == notification.data._id) + self.busy(false) + }; + App.on('movie.searcher.ended', self.global_events['movie.searcher.ended']); + + // Reload when releases have updated + self.global_events['release.update_status'] = function(notification){ + var data = notification.data; + if(data && self.data._id == data.movie_id){ + + if(!self.data.releases) + self.data.releases = []; + + self.data.releases.push({'quality': data.quality, 'status': data.status}); + self.updateReleases(); + } + }; + + App.on('release.update_status', self.global_events['release.update_status']); + + }, + + destroy: function(){ + var self = this; + + self.el.destroy(); + delete self.list.movies_added[self.get('id')]; + self.list.movies.erase(self); + + self.list.checkIfEmpty(); + + // Remove events + Object.each(self.global_events, function(handle, listener){ + App.off(listener, handle); + }); + }, + + busy: function(set_busy, timeout){ + var self = this; + + if(!set_busy){ + setTimeout(function(){ + if(self.spinner){ + self.mask.fade('out'); + setTimeout(function(){ + if(self.mask) + self.mask.destroy(); + if(self.spinner) + self.spinner.el.destroy(); + self.spinner = null; + self.mask = null; + }, timeout || 400); + } + }, timeout || 1000) + } + else if(!self.spinner) { + self.createMask(); + self.spinner = createSpinner(self.mask); + self.mask.fade('in'); + } + }, + + createMask: function(){ + var self = this; + self.mask = new Element('div.mask', { + 'styles': { + 'z-index': 4 + } + }).inject(self.el, 'top').fade('hide'); + }, + + update: function(notification){ + var self = this; + + self.data = notification.data; + self.el.empty(); + self.removeView(); + + self.profile = Quality.getProfile(self.data.profile_id) || {}; + self.category = CategoryList.getCategory(self.data.category_id) || {}; + self.create(); + + self.busy(false); + }, + + create: function(){ + var self = this; + + self.el.addClass('status_'+self.get('status')); + + var eta = null, + eta_date = null, + now = Math.round(+new Date()/1000); + + if(self.data.info.release_date) + [self.data.info.release_date.dvd, self.data.info.release_date.theater].each(function(timestamp){ + if (timestamp > 0 && (eta == null || Math.abs(timestamp - now) < Math.abs(eta - now))) + eta = timestamp; + }); + + if(eta){ + eta_date = new Date(eta * 1000); + eta_date = eta_date.toLocaleString('en-us', { month: "long" }) + ' ' + eta_date.getFullYear(); + } + + self.el.adopt( + self.select_checkbox = new Element('input[type=checkbox].inlay', { + 'events': { + 'change': function(){ + self.fireEvent('select') + } + } + }), + self.thumbnail = (self.data.files && self.data.files.image_poster) ? new Element('img', { + 'class': 'type_image poster', + 'src': Api.createUrl('file.cache') + self.data.files.image_poster[0].split(Api.getOption('path_sep')).pop() + }): null, + self.data_container = new Element('div.data.inlay.light').adopt( + self.info_container = new Element('div.info').adopt( + new Element('div.title').adopt( + self.title = new Element('a', { + 'events': { + 'click': function(e){ + self.episodes.open(); + } + }, + 'text': self.getTitle() || 'n/a' + }), + self.year = new Element('div.year', { + 'text': self.data.info.year || 'n/a' + }) + ), + self.description = new Element('div.description.tiny_scroll', { + 'text': self.data.info.plot + }), + self.eta = eta_date && (now+8035200 > eta) ? new Element('div.eta', { + 'text': eta_date, + 'title': 'ETA' + }) : null, + self.quality = new Element('div.quality', { + 'events': { + 'click': function(e){ + var releases = self.el.getElement('.actions .releases'); + if(releases.isVisible()) + releases.fireEvent('click', [e]) + } + } + }) + ), + self.actions = new Element('div.actions') + ) + ); + + if(!self.thumbnail) + self.el.addClass('no_thumbnail'); + + //self.changeView(self.view); + self.select_checkbox_class = new Form.Check(self.select_checkbox); + + // Add profile + if(self.profile.data) + self.profile.getTypes().each(function(type){ + + var q = self.addQuality(type.get('quality'), type.get('3d')); + if((type.finish == true || type.get('finish')) && !q.hasClass('finish')){ + q.addClass('finish'); + q.set('title', q.get('title') + ' Will finish searching for this movie if this quality is found.') + } + + }); + + // Add releases + self.updateReleases(); + + Object.each(self.options.actions, function(action, key){ + self.action[key.toLowerCase()] = action = new self.options.actions[key](self); + if(action.el) + self.actions.adopt(action) + }); + + }, + + updateReleases: function(){ + var self = this; + if(!self.data.releases || self.data.releases.length == 0) return; + + self.data.releases.each(function(release){ + + var q = self.quality.getElement('.q_'+ release.quality+(release.is_3d ? '.is_3d' : ':not(.is_3d)')), + status = release.status; + + if(!q && (status == 'snatched' || status == 'seeding' || status == 'done')) + q = self.addQuality(release.quality, release.is_3d || false); + + if (q && !q.hasClass(status)){ + q.addClass(status); + q.set('title', (q.get('title') ? q.get('title') : '') + ' status: '+ status) + } + + }); + }, + + addQuality: function(quality, is_3d){ + var self = this; + + var q = Quality.getQuality(quality); + return new Element('span', { + 'text': q.label + (is_3d ? ' 3D' : ''), + 'class': 'q_'+q.identifier + (is_3d ? ' is_3d' : ''), + 'title': '' + }).inject(self.quality); + + }, + + getTitle: function(){ + var self = this; + + if(self.data.title) + return self.getUnprefixedTitle(self.data.title); + else if(self.data.info.titles.length > 0) + return self.getUnprefixedTitle(self.data.info.titles[0]); + + return 'Unknown movie' + }, + + getUnprefixedTitle: function(t){ + if(t.substr(0, 4).toLowerCase() == 'the ') + t = t.substr(4) + ', The'; + else if(t.substr(0, 3).toLowerCase() == 'an ') + t = t.substr(3) + ', An'; + else if(t.substr(0, 2).toLowerCase() == 'a ') + t = t.substr(2) + ', A'; + return t; + }, + + slide: function(direction, el, expand){ + var self = this; + + if(direction == 'in'){ + self.temp_view = self.view; + self.changeView('details'); + + self.el.addEvent('outerClick', function(){ + self.removeView(); + self.slide('out') + }); + el.show(); + + + if(expand === true) { + self.el.addClass('expanded'); + self.el.getElements('.table').addClass('expanded'); + } + + self.data_container.addClass('hide_right'); + } + else { + self.el.removeEvents('outerClick'); + + setTimeout(function(){ + if(self.el) + { + self.el.getElements('> :not(.data):not(.poster):not(.movie_container)').hide(); + self.el.getElements('.table').removeClass('expanded'); + } + }, 600); + + self.el.removeClass('expanded'); + self.data_container.removeClass('hide_right'); + } + }, + + changeView: function(new_view){ + var self = this; + + if(self.el) + self.el + .removeClass(self.view+'_view') + .addClass(new_view+'_view'); + + self.view = new_view; + }, + + removeView: function(){ + var self = this; + + self.el.removeClass(self.view+'_view') + }, + + getIdentifier: function(){ + var self = this; + + try { + return self.get('identifiers').imdb; + } + catch (e){ } + + return self.get('imdb'); + }, + + get: function(attr){ + return this.data[attr] || this.data.info[attr] + }, + + select: function(bool){ + var self = this; + self.select_checkbox_class[bool ? 'check' : 'uncheck']() + }, + + isSelected: function(){ + return this.select_checkbox.get('checked'); + }, + + toElement: function(){ + return this.el; + } + +}); diff --git a/couchpotato/core/media/show/episode.py b/couchpotato/core/media/show/episode.py deleted file mode 100644 index c4a0a1a..0000000 --- a/couchpotato/core/media/show/episode.py +++ /dev/null @@ -1,147 +0,0 @@ -from couchpotato import get_db -from couchpotato.core.event import addEvent, fireEvent, fireEventAsync -from couchpotato.core.logger import CPLog -from couchpotato.core.helpers.variable import tryInt -from couchpotato.core.media import MediaBase - - -log = CPLog(__name__) - -autoload = 'Episode' - - -class Episode(MediaBase): - - def __init__(self): - addEvent('media.search_query', self.query) - addEvent('media.identifier', self.identifier) - - addEvent('show.episode.add', self.add) - addEvent('show.episode.update_info', self.updateInfo) - - def add(self, parent_id, info = None, update_after = True): - if not info: info = {} - - identifiers = info.get('identifiers') - try: del info['identifiers'] - except: pass - - # Add Season - episode_info = { - '_t': 'media', - 'type': 'episode', - 'identifiers': identifiers, - 'parent_id': parent_id, - 'info': info, # Returned dict by providers - } - - # Check if season already exists - existing_episode = fireEvent('media.with_identifiers', identifiers, with_doc = True, single = True) - - db = get_db() - - if existing_episode: - s = existing_episode['doc'] - s.update(episode_info) - episode = db.update(s) - else: - episode = db.insert(episode_info) - - # Update library info - if update_after is not False: - handle = fireEventAsync if update_after is 'async' else fireEvent - handle('show.season.update_info', episode.get('_id'), info = info, single = True) - - return episode - - def updateInfo(self, media_id = None, info = None, force = False): - if not info: info = {} - - if self.shuttingDown(): - return - - db = get_db() - - episode = db.get('id', media_id) - - # Get new info - if not info: - info = fireEvent('episode.info', episode.get('identifiers'), merge = True) - - # Update/create media - if force: - - episode['identifiers'].update(info['identifiers']) - if 'identifiers' in info: - del info['identifiers'] - - episode.update({'info': info}) - e = db.update(episode) - episode.update(e) - - # Get images - image_urls = info.get('images', []) - existing_files = episode.get('files', {}) - self.getPoster(image_urls, existing_files) - - return episode - - def query(self, library, first = True, condense = True, include_identifier = True, **kwargs): - if library is list or library.get('type') != 'episode': - return - - # Get the titles of the season - if not library.get('related_libraries', {}).get('season', []): - log.warning('Invalid library, unable to determine title.') - return - - titles = fireEvent( - 'media.search_query', - library['related_libraries']['season'][0], - first=False, - include_identifier=include_identifier, - condense=condense, - - single=True - ) - - identifier = fireEvent('media.identifier', library, single = True) - - # Add episode identifier to titles - if include_identifier and identifier.get('episode'): - titles = [title + ('E%02d' % identifier['episode']) for title in titles] - - - if first: - return titles[0] if titles else None - - return titles - - - def identifier(self, media): - if media.get('type') != 'episode': - return - - identifier = { - 'season': None, - 'episode': None - } - - scene_map = media['info'].get('map_episode', {}).get('scene') - - if scene_map: - # Use scene mappings if they are available - identifier['season'] = scene_map.get('season_nr') - identifier['episode'] = scene_map.get('episode_nr') - else: - # Fallback to normal season/episode numbers - identifier['season'] = media['info'].get('season_number') - identifier['episode'] = media['info'].get('number') - - - # Cast identifiers to integers - # TODO this will need changing to support identifiers with trailing 'a', 'b' characters - identifier['season'] = tryInt(identifier['season'], None) - identifier['episode'] = tryInt(identifier['episode'], None) - - return identifier diff --git a/couchpotato/core/media/show/library/__init__.py b/couchpotato/core/media/show/library/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/couchpotato/core/media/show/library/episode.py b/couchpotato/core/media/show/library/episode.py new file mode 100755 index 0000000..26b5c3d --- /dev/null +++ b/couchpotato/core/media/show/library/episode.py @@ -0,0 +1,71 @@ +from couchpotato.core.event import addEvent, fireEvent +from couchpotato.core.helpers.variable import tryInt +from couchpotato.core.logger import CPLog +from couchpotato.core.media._base.library.base import LibraryBase + +log = CPLog(__name__) + +autoload = 'EpisodeLibraryPlugin' + + +class EpisodeLibraryPlugin(LibraryBase): + def __init__(self): + addEvent('library.query', self.query) + addEvent('library.identifier', self.identifier) + + def query(self, media, first = True, condense = True, include_identifier = True, **kwargs): + if media.get('type') != 'show.episode': + return + + related = fireEvent('library.related', media, single = True) + + # Get season titles + titles = fireEvent( + 'library.query', related['season'], + + first = False, + include_identifier = include_identifier, + condense = condense, + + single = True + ) + + # Add episode identifier to titles + if include_identifier: + identifier = fireEvent('library.identifier', media, single = True) + + if identifier and identifier.get('episode'): + titles = [title + ('E%02d' % identifier['episode']) for title in titles] + + if first: + return titles[0] if titles else None + + return titles + + def identifier(self, media): + if media.get('type') != 'show.episode': + return + + identifier = { + 'season': None, + 'episode': None + } + + # TODO identifier mapping + # scene_map = media['info'].get('map_episode', {}).get('scene') + + # if scene_map: + # # Use scene mappings if they are available + # identifier['season'] = scene_map.get('season_nr') + # identifier['episode'] = scene_map.get('episode_nr') + # else: + # Fallback to normal season/episode numbers + identifier['season'] = media['info'].get('season_number') + identifier['episode'] = media['info'].get('number') + + # Cast identifiers to integers + # TODO this will need changing to support identifiers with trailing 'a', 'b' characters + identifier['season'] = tryInt(identifier['season'], None) + identifier['episode'] = tryInt(identifier['episode'], None) + + return identifier diff --git a/couchpotato/core/media/show/library/season.py b/couchpotato/core/media/show/library/season.py new file mode 100755 index 0000000..89489af --- /dev/null +++ b/couchpotato/core/media/show/library/season.py @@ -0,0 +1,52 @@ +from couchpotato.core.event import addEvent, fireEvent +from couchpotato.core.helpers.variable import tryInt +from couchpotato.core.logger import CPLog +from couchpotato.core.media._base.library.base import LibraryBase + +log = CPLog(__name__) + +autoload = 'SeasonLibraryPlugin' + + +class SeasonLibraryPlugin(LibraryBase): + def __init__(self): + addEvent('library.query', self.query) + addEvent('library.identifier', self.identifier) + + def query(self, media, first = True, condense = True, include_identifier = True, **kwargs): + if media.get('type') != 'show.season': + return + + related = fireEvent('library.related', media, single = True) + + # Get show titles + titles = fireEvent( + 'library.query', related['show'], + + first = False, + condense = condense, + + single = True + ) + + # TODO map_names + + # Add season identifier to titles + if include_identifier: + identifier = fireEvent('library.identifier', media, single = True) + + if identifier and identifier.get('season') is not None: + titles = [title + (' S%02d' % identifier['season']) for title in titles] + + if first: + return titles[0] if titles else None + + return titles + + def identifier(self, media): + if media.get('type') != 'show.season': + return + + return { + 'season': tryInt(media['info']['number'], None) + } diff --git a/couchpotato/core/media/show/library/show.py b/couchpotato/core/media/show/library/show.py new file mode 100644 index 0000000..168804c --- /dev/null +++ b/couchpotato/core/media/show/library/show.py @@ -0,0 +1,38 @@ +from couchpotato.core.event import addEvent +from couchpotato.core.helpers.encoding import simplifyString +from couchpotato.core.logger import CPLog +from couchpotato.core.media._base.library.base import LibraryBase +from qcond import QueryCondenser + +log = CPLog(__name__) + +autoload = 'ShowLibraryPlugin' + + +class ShowLibraryPlugin(LibraryBase): + query_condenser = QueryCondenser() + + def __init__(self): + addEvent('library.query', self.query) + + def query(self, media, first = True, condense = True, include_identifier = True, **kwargs): + if media.get('type') != 'show': + return + + titles = media['info']['titles'] + + if condense: + # Use QueryCondenser to build a list of optimal search titles + condensed_titles = self.query_condenser.distinct(titles) + + if condensed_titles: + # Use condensed titles if we got a valid result + titles = condensed_titles + else: + # Fallback to simplifying titles + titles = [simplifyString(title) for title in titles] + + if first: + return titles[0] if titles else None + + return titles diff --git a/couchpotato/core/media/show/matcher/__init__.py b/couchpotato/core/media/show/matcher/__init__.py new file mode 100755 index 0000000..e2e607a --- /dev/null +++ b/couchpotato/core/media/show/matcher/__init__.py @@ -0,0 +1,7 @@ +from .main import ShowMatcher + + +def autoload(): + return ShowMatcher() + +config = [] diff --git a/couchpotato/core/media/show/matcher.py b/couchpotato/core/media/show/matcher/base.py old mode 100644 new mode 100755 similarity index 55% rename from couchpotato/core/media/show/matcher.py rename to couchpotato/core/media/show/matcher/base.py index 4056d64..186334f --- a/couchpotato/core/media/show/matcher.py +++ b/couchpotato/core/media/show/matcher/base.py @@ -1,19 +1,9 @@ -from couchpotato import CPLog -from couchpotato.core.event import addEvent, fireEvent -from couchpotato.core.helpers.variable import tryInt +from couchpotato import fireEvent, CPLog, tryInt +from couchpotato.core.event import addEvent from couchpotato.core.media._base.matcher.base import MatcherBase -from couchpotato.core.media._base.providers.base import MultiProvider log = CPLog(__name__) -autoload = 'ShowMatcher' - - -class ShowMatcher(MultiProvider): - - def getTypes(self): - return [Season, Episode] - class Base(MatcherBase): @@ -80,51 +70,3 @@ class Base(MatcherBase): identifier[key] = tryInt(value, value) return identifier - - -class Episode(Base): - type = 'episode' - - def correctIdentifier(self, chain, media): - identifier = self.getChainIdentifier(chain) - if not identifier: - log.info2('Wrong: release identifier is not valid (unsupported or missing identifier)') - return False - - # TODO - Parse episode ranges from identifier to determine if they are multi-part episodes - if any([x in identifier for x in ['episode_from', 'episode_to']]): - log.info2('Wrong: releases with identifier ranges are not supported yet') - return False - - required = fireEvent('media.identifier', media['library'], single = True) - - # TODO - Support air by date episodes - # TODO - Support episode parts - - if identifier != required: - log.info2('Wrong: required identifier (%s) does not match release identifier (%s)', (required, identifier)) - return False - - return True - -class Season(Base): - type = 'season' - - def correctIdentifier(self, chain, media): - identifier = self.getChainIdentifier(chain) - if not identifier: - log.info2('Wrong: release identifier is not valid (unsupported or missing identifier)') - return False - - # TODO - Parse episode ranges from identifier to determine if they are season packs - if any([x in identifier for x in ['episode_from', 'episode_to']]): - log.info2('Wrong: releases with identifier ranges are not supported yet') - return False - - required = fireEvent('media.identifier', media['library'], single = True) - - if identifier != required: - log.info2('Wrong: required identifier (%s) does not match release identifier (%s)', (required, identifier)) - return False - - return True diff --git a/couchpotato/core/media/show/matcher/episode.py b/couchpotato/core/media/show/matcher/episode.py new file mode 100755 index 0000000..fb8a37e --- /dev/null +++ b/couchpotato/core/media/show/matcher/episode.py @@ -0,0 +1,30 @@ +from couchpotato import fireEvent, CPLog +from couchpotato.core.media.show.matcher.base import Base + +log = CPLog(__name__) + + +class Episode(Base): + type = 'show.episode' + + def correctIdentifier(self, chain, media): + identifier = self.getChainIdentifier(chain) + if not identifier: + log.info2('Wrong: release identifier is not valid (unsupported or missing identifier)') + return False + + # TODO - Parse episode ranges from identifier to determine if they are multi-part episodes + if any([x in identifier for x in ['episode_from', 'episode_to']]): + log.info2('Wrong: releases with identifier ranges are not supported yet') + return False + + required = fireEvent('library.identifier', media, single = True) + + # TODO - Support air by date episodes + # TODO - Support episode parts + + if identifier != required: + log.info2('Wrong: required identifier (%s) does not match release identifier (%s)', (required, identifier)) + return False + + return True diff --git a/couchpotato/core/media/show/matcher/main.py b/couchpotato/core/media/show/matcher/main.py new file mode 100755 index 0000000..e9eee6c --- /dev/null +++ b/couchpotato/core/media/show/matcher/main.py @@ -0,0 +1,9 @@ +from couchpotato.core.media._base.providers.base import MultiProvider +from couchpotato.core.media.show.matcher.episode import Episode +from couchpotato.core.media.show.matcher.season import Season + + +class ShowMatcher(MultiProvider): + + def getTypes(self): + return [Season, Episode] diff --git a/couchpotato/core/media/show/matcher/season.py b/couchpotato/core/media/show/matcher/season.py new file mode 100755 index 0000000..2bc64ca --- /dev/null +++ b/couchpotato/core/media/show/matcher/season.py @@ -0,0 +1,27 @@ +from couchpotato import fireEvent, CPLog +from couchpotato.core.media.show.matcher.base import Base + +log = CPLog(__name__) + + +class Season(Base): + type = 'show.season' + + def correctIdentifier(self, chain, media): + identifier = self.getChainIdentifier(chain) + if not identifier: + log.info2('Wrong: release identifier is not valid (unsupported or missing identifier)') + return False + + # TODO - Parse episode ranges from identifier to determine if they are season packs + if any([x in identifier for x in ['episode_from', 'episode_to']]): + log.info2('Wrong: releases with identifier ranges are not supported yet') + return False + + required = fireEvent('library.identifier', media, single = True) + + if identifier != required: + log.info2('Wrong: required identifier (%s) does not match release identifier (%s)', (required, identifier)) + return False + + return True diff --git a/couchpotato/core/media/show/providers/base.py b/couchpotato/core/media/show/providers/base.py old mode 100644 new mode 100755 index 8ad4a7a..15ec388 --- a/couchpotato/core/media/show/providers/base.py +++ b/couchpotato/core/media/show/providers/base.py @@ -6,8 +6,8 @@ class ShowProvider(BaseInfoProvider): class SeasonProvider(BaseInfoProvider): - type = 'season' + type = 'show.season' class EpisodeProvider(BaseInfoProvider): - type = 'episode' + type = 'show.episode' diff --git a/couchpotato/core/media/show/providers/info/thetvdb.py b/couchpotato/core/media/show/providers/info/thetvdb.py index b2e58d9..e1d749f 100755 --- a/couchpotato/core/media/show/providers/info/thetvdb.py +++ b/couchpotato/core/media/show/providers/info/thetvdb.py @@ -12,7 +12,6 @@ from couchpotato.core.media.show.providers.base import ShowProvider from tvdb_api import tvdb_exceptions from tvdb_api.tvdb_api import Tvdb, Show - log = CPLog(__name__) autoload = 'TheTVDb' @@ -26,8 +25,6 @@ class TheTVDb(ShowProvider): # TODO: Expose apikey in setting so it can be changed by user def __init__(self): - addEvent('info.search', self.search, priority = 1) - addEvent('show.search', self.search, priority = 1) addEvent('show.info', self.getShowInfo, priority = 1) addEvent('season.info', self.getSeasonInfo, priority = 1) addEvent('episode.info', self.getEpisodeInfo, priority = 1) @@ -44,56 +41,6 @@ class TheTVDb(ShowProvider): self.tvdb = Tvdb(**self.tvdb_api_parms) self.valid_languages = self.tvdb.config['valid_languages'] - def search(self, q, limit = 12, language = 'en'): - ''' Find show by name - show = { 'id': 74713, - 'language': 'en', - 'lid': 7, - 'seriesid': '74713', - 'seriesname': u'Breaking Bad',} - ''' - - if self.isDisabled(): - return False - - if language != self.tvdb_api_parms['language'] and language in self.valid_languages: - self.tvdb_api_parms['language'] = language - self._setup() - - search_string = simplifyString(q) - cache_key = 'thetvdb.cache.search.%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 False - - results = [] - if raw: - try: - nr = 0 - for show_info in raw: - - results.append(self._parseShow(show_info)) - 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 (tvdb_exceptions.tvdb_error, IOError), e: - log.error('Failed parsing TheTVDB for "%s": %s', (q, traceback.format_exc())) - return False - - return results - def getShow(self, identifier = None): show = None try: @@ -129,22 +76,17 @@ class TheTVDb(ShowProvider): return result or {} - def getSeasonInfo(self, identifier = None, params = {}): + def getSeasonInfo(self, identifiers = None, params = {}): """Either return a list of all seasons or a single season by number. identifier is the show 'id' """ - if not identifier: - return False - - season_identifier = params.get('season_identifier', None) + if not identifiers or not identifiers.get('thetvdb'): + return None - # season_identifier must contain the 'show id : season number' since there is no tvdb id - # for season and we need a reference to both the show id and season number - if season_identifier: - try: season_identifier = int(season_identifier.split(':')[1]) - except: return False + season_number = params.get('season_number', None) + identifier = tryInt(identifiers.get('thetvdb')) - cache_key = 'thetvdb.cache.%s.%s' % (identifier, season_identifier) + cache_key = 'thetvdb.cache.%s.%s' % (identifier, season_number) log.debug('Getting SeasonInfo: %s', cache_key) result = self.getCache(cache_key) or {} if result: @@ -158,12 +100,12 @@ class TheTVDb(ShowProvider): result = [] for number, season in show.items(): - if season_identifier is not None and number == season_identifier: - result = self._parseSeason(show, (number, season)) + if season_number is not None and number == season_number: + result = self._parseSeason(show, number, season) self.setCache(cache_key, result) return result else: - result.append(self._parseSeason(show, (number, season))) + result.append(self._parseSeason(show, number, season)) self.setCache(cache_key, result) return result @@ -172,21 +114,22 @@ class TheTVDb(ShowProvider): """Either return a list of all episodes or a single episode. If episode_identifer contains an episode number to search for """ - season_identifier = params.get('season_identifier', None) - episode_identifier = params.get('episode_identifier', None) + season_number = self.getIdentifier(params.get('season_number', None)) + episode_identifier = self.getIdentifier(params.get('episode_identifiers', None)) + identifier = self.getIdentifier(identifier) - if not identifier and season_identifier is None: + if not identifier and season_number is None: return False # season_identifier must contain the 'show id : season number' since there is no tvdb id # for season and we need a reference to both the show id and season number - if season_identifier: + if not identifier and season_number: try: - identifier, season_identifier = season_identifier.split(':') - season_identifier = int(season_identifier) + identifier, season_number = season_number.split(':') + season_number = int(season_number) except: return None - cache_key = 'thetvdb.cache.%s.%s.%s' % (identifier, episode_identifier, season_identifier) + cache_key = 'thetvdb.cache.%s.%s.%s' % (identifier, episode_identifier, season_number) log.debug('Getting EpisodeInfo: %s', cache_key) result = self.getCache(cache_key) or {} if result: @@ -200,20 +143,26 @@ class TheTVDb(ShowProvider): result = [] for number, season in show.items(): - if season_identifier is not None and number != season_identifier: + if season_number is not None and number != season_number: continue for episode in season.values(): if episode_identifier is not None and episode['id'] == toUnicode(episode_identifier): - result = self._parseEpisode(show, episode) + result = self._parseEpisode(episode) self.setCache(cache_key, result) return result else: - result.append(self._parseEpisode(show, episode)) + result.append(self._parseEpisode(episode)) self.setCache(cache_key, result) return result + def getIdentifier(self, value): + if type(value) is dict: + return value.get('thetvdb') + + return value + def _parseShow(self, show): # @@ -397,9 +346,9 @@ class TheTVDb(ShowProvider): def isDisabled(self): if self.conf('api_key') == '': log.error('No API key provided.') - True + return True else: - False + return False config = [{ diff --git a/couchpotato/core/media/show/providers/info/trakt.py b/couchpotato/core/media/show/providers/info/trakt.py new file mode 100755 index 0000000..cac37c1 --- /dev/null +++ b/couchpotato/core/media/show/providers/info/trakt.py @@ -0,0 +1,86 @@ +import urllib + +from couchpotato.core.event import addEvent +from couchpotato.core.logger import CPLog +from couchpotato.core.media.show.providers.base import ShowProvider + +log = CPLog(__name__) + +autoload = 'Trakt' + + +class Trakt(ShowProvider): + api_key = 'c043de5ada9d180028c10229d2a3ea5b' + base_url = 'http://api.trakt.tv/%%s.json/%s' % api_key + + def __init__(self): + addEvent('info.search', self.search, priority = 1) + addEvent('show.search', self.search, priority = 1) + + def search(self, q, limit = 12): + if self.isDisabled(): + return False + + # Check for cached result + cache_key = 'trakt.cache.search.%s.%s' % (q, limit) + results = self.getCache(cache_key) or [] + + if results: + return results + + # Search + log.debug('Searching for show: "%s"', q) + response = self._request('search/shows', query=q, limit=limit) + + if not response: + return [] + + # Parse search results + for show in response: + results.append(self._parseShow(show)) + + log.info('Found: %s', [result['titles'][0] + ' (' + str(result.get('year', 0)) + ')' for result in results]) + + self.setCache(cache_key, results) + return results + + def _request(self, action, **kwargs): + url = self.base_url % action + + if kwargs: + url += '?' + urllib.urlencode(kwargs) + + return self.getJsonData(url) + + def _parseShow(self, show): + # Images + images = show.get('images', {}) + + poster = images.get('poster') + backdrop = images.get('backdrop') + + # Rating + rating = show.get('ratings', {}).get('percentage') + + # Build show dict + show_data = { + 'identifiers': { + 'thetvdb': show.get('tvdb_id'), + 'imdb': show.get('imdb_id'), + 'tvrage': show.get('tvrage_id'), + }, + 'type': 'show', + 'titles': [show.get('title')], + 'images': { + 'poster': [poster] if poster else [], + 'backdrop': [backdrop] if backdrop else [], + 'poster_original': [], + 'backdrop_original': [], + }, + 'year': show.get('year'), + 'rating': { + 'trakt': float(rating) / 10 + }, + } + + return dict((k, v) for k, v in show_data.iteritems() if v) diff --git a/couchpotato/core/media/show/providers/info/xem.py b/couchpotato/core/media/show/providers/info/xem.py old mode 100644 new mode 100755 index ec7d343..f8c003c --- a/couchpotato/core/media/show/providers/info/xem.py +++ b/couchpotato/core/media/show/providers/info/xem.py @@ -5,6 +5,8 @@ from couchpotato.core.media.show.providers.base import ShowProvider log = CPLog(__name__) +autoload = 'Xem' + class Xem(ShowProvider): ''' @@ -75,76 +77,69 @@ class Xem(ShowProvider): self.config['url_names'] = u"%(base_url)s/map/names?" % self.config self.config['url_all_names'] = u"%(base_url)s/map/allNames?" % self.config - # TODO: Also get show aliases (store as titles) - def getShowInfo(self, identifier = None): + def getShowInfo(self, identifiers = None): if self.isDisabled(): return {} + identifier = identifiers.get('thetvdb') + + if not identifier: + return {} + cache_key = 'xem.cache.%s' % identifier log.debug('Getting showInfo: %s', cache_key) result = self.getCache(cache_key) or {} if result: return result + result['seasons'] = {} + # Create season/episode and absolute mappings - url = self.config['url_all'] + "id=%s&origin=tvdb" % tryUrlencode(identifier) + url = self.config['url_all'] + "id=%s&origin=tvdb" % tryUrlencode(identifier) response = self.getJsonData(url) - if response: - if response.get('result') == 'success': - data = response.get('data', None) - result = self._parse(data) + + if response and response.get('result') == 'success': + data = response.get('data', None) + self.parseMaps(result, data) # Create name alias mappings - url = self.config['url_names'] + "id=%s&origin=tvdb" % tryUrlencode(identifier) + url = self.config['url_names'] + "id=%s&origin=tvdb" % tryUrlencode(identifier) response = self.getJsonData(url) - if response: - if response.get('result') == 'success': - data = response.get('data', None) - result.update({'map_names': data}) + + if response and response.get('result') == 'success': + data = response.get('data', None) + self.parseNames(result, data) self.setCache(cache_key, result) return result - def getEpisodeInfo(self, identifier = None, params = {}): - episode = params.get('episode', None) - if episode is None: + def getEpisodeInfo(self, identifiers = None, params = {}): + episode_num = params.get('episode_number', None) + if episode_num is None: return False - season_identifier = params.get('season_identifier', None) - if season_identifier is None: + season_num = params.get('season_number', None) + if season_num is None: return False - episode_identifier = params.get('episode_identifier', None) - absolute = params.get('absolute', None) - - # season_identifier must contain the 'show id : season number' since there is no tvdb id - # for season and we need a reference to both the show id and season number - if season_identifier: - try: - identifier, season_identifier = season_identifier.split(':') - season = int(season_identifier) - except: return False + result = self.getShowInfo(identifiers) - result = self.getShowInfo(identifier) - map = {} - if result: - map_episode = result.get('map_episode', {}).get(season, {}).get(episode, {}) - if map_episode: - map.update({'map_episode': map_episode}) + if not result: + return False - if absolute: - map_absolute = result.get('map_absolute', {}).get(absolute, {}) - if map_absolute: - map.update({'map_absolute': map_absolute}) + # Find season + if season_num not in result['seasons']: + return False - map_names = result.get('map_names', {}).get(toUnicode(season), {}) - if map_names: - map.update({'map_names': map_names}) + season = result['seasons'][season_num] - return map + # Find episode + if episode_num not in season['episodes']: + return False + return season['episodes'][episode_num] - def _parse(self, data, master = 'tvdb'): + def parseMaps(self, result, data, master = 'tvdb'): '''parses xem map and returns a custom formatted dict map To retreive map for scene: @@ -152,17 +147,44 @@ class Xem(ShowProvider): print map['map_episode'][1][1]['scene']['season'] ''' if not isinstance(data, list): - return {} + return - map = {'map_episode': {}, 'map_absolute': {}} - for maps in data: - origin = maps.pop(master, None) + for episode_map in data: + origin = episode_map.pop(master, None) if origin is None: - continue # No master origin to map to - map.get('map_episode').setdefault(origin['season'], {}).setdefault(origin['episode'], maps.copy()) - map.get('map_absolute').setdefault(origin['absolute'], maps.copy()) + continue # No master origin to map to + + o_season = origin['season'] + o_episode = origin['episode'] + + # Create season info + if o_season not in result['seasons']: + result['seasons'][o_season] = {} + + season = result['seasons'][o_season] + + if 'episodes' not in season: + season['episodes'] = {} + + # Create episode info + if o_episode not in season['episodes']: + season['episodes'][o_episode] = {} + + episode = season['episodes'][o_episode] + episode['episode_map'] = episode_map + + def parseNames(self, result, data): + result['title_map'] = data.pop('all', None) + + for season, title_map in data.items(): + season = int(season) + + # Create season info + if season not in result['seasons']: + result['seasons'][season] = {} - return map + season = result['seasons'][season] + season['title_map'] = title_map def isDisabled(self): if __name__ == '__main__': diff --git a/couchpotato/core/media/show/providers/nzb/newznab.py b/couchpotato/core/media/show/providers/nzb/newznab.py index e376d05..4354611 100644 --- a/couchpotato/core/media/show/providers/nzb/newznab.py +++ b/couchpotato/core/media/show/providers/nzb/newznab.py @@ -18,15 +18,15 @@ class Newznab(MultiProvider): class Season(SeasonProvider, Base): - def buildUrl(self, media, api_key): - search_title = fireEvent('media.search_query', media, include_identifier = False, single = True) - identifier = fireEvent('media.identifier', media, single = True) + def buildUrl(self, media, host): + related = fireEvent('library.related', media, single = True) + identifier = fireEvent('library.identifier', media, single = True) query = tryUrlencode({ 't': 'tvsearch', - 'q': search_title, + 'apikey': host['api_key'], + 'q': related['show']['title'], 'season': identifier['season'], - 'apikey': api_key, 'extended': 1 }) return query @@ -34,16 +34,15 @@ class Season(SeasonProvider, Base): class Episode(EpisodeProvider, Base): - def buildUrl(self, media, api_key): - search_title = fireEvent('media.search_query', media['show'], include_identifier = False, single = True) - identifier = fireEvent('media.identifier', media, single = True) - + def buildUrl(self, media, host): + related = fireEvent('library.related', media, single = True) + identifier = fireEvent('library.identifier', media, single = True) query = tryUrlencode({ 't': 'tvsearch', - 'q': search_title, + 'apikey': host['api_key'], + 'q': related['show']['title'], 'season': identifier['season'], 'ep': identifier['episode'], - 'apikey': api_key, 'extended': 1 }) diff --git a/couchpotato/core/media/show/searcher.py b/couchpotato/core/media/show/searcher.py deleted file mode 100644 index 255d12d..0000000 --- a/couchpotato/core/media/show/searcher.py +++ /dev/null @@ -1,232 +0,0 @@ -import time -from couchpotato import Env, get_db -from couchpotato.core.event import addEvent, fireEvent -from couchpotato.core.helpers.variable import getTitle, toIterable -from couchpotato.core.logger import CPLog -from couchpotato.core.media._base.searcher.base import SearcherBase -from couchpotato.core.media._base.searcher.main import SearchSetupError -from couchpotato.core.media.show import ShowTypeBase -from qcond import QueryCondenser - -log = CPLog(__name__) - -autoload = 'ShowSearcher' - - -class ShowSearcher(SearcherBase, ShowTypeBase): - - type = ['show', 'season', 'episode'] - - in_progress = False - - def __init__(self): - super(ShowSearcher, self).__init__() - - self.query_condenser = QueryCondenser() - - addEvent('season.searcher.single', self.singleSeason) - addEvent('episode.searcher.single', self.singleEpisode) - - addEvent('searcher.correct_release', self.correctRelease) - addEvent('searcher.get_search_title', self.getSearchTitle) - - - - def single(self, media, search_protocols = None, manual = False): - - # Find out search type - try: - if not search_protocols: - search_protocols = fireEvent('searcher.protocols', single = True) - except SearchSetupError: - return - - if not media['profile_id'] or media['status'] == 'done': - log.debug('Show doesn\'t have a profile or already done, assuming in manage tab.') - return - - show_title = fireEvent('media.search_query', media, condense = False, single = True) - - fireEvent('notify.frontend', type = 'show.searcher.started.%s' % media['_id'], data = True, message = 'Searching for "%s"' % show_title) - - media = self.extendShow(media) - - db = get_db() - - profile = db.get('id', media['profile_id']) - quality_order = fireEvent('quality.order', single = True) - - seasons = media.get('seasons', {}) - for sx in seasons: - - # Skip specials for now TODO: set status for specials to skipped by default - if sx == 0: continue - - season = seasons.get(sx) - - # Check if full season can be downloaded TODO: add - season_success = self.singleSeason(season, media, profile) - - # Do each episode seperately - if not season_success: - episodes = season.get('episodes', {}) - for ex in episodes: - episode = episodes.get(ex) - - self.singleEpisode(episode, season, media, profile, quality_order, search_protocols) - - # TODO - return - - # TODO - return - - fireEvent('notify.frontend', type = 'show.searcher.ended.%s' % media['_id'], data = True) - - def singleSeason(self, media, show, profile): - - # Check if any episode is already snatched - active = 0 - episodes = media.get('episodes', {}) - for ex in episodes: - episode = episodes.get(ex) - - if episode.get('status') in ['active']: - active += 1 - - if active != len(episodes): - return False - - # Try and search for full season - # TODO: - - return False - - def singleEpisode(self, media, season, show, profile, quality_order, search_protocols = None, manual = False): - - - # TODO: check episode status - - - # TODO: check air date - #if not self.conf('always_search') and not self.couldBeReleased(quality_type['quality']['identifier'] in pre_releases, release_dates, movie['library']['year']): - # too_early_to_search.append(quality_type['quality']['identifier']) - # return - - ret = False - has_better_quality = None - found_releases = [] - too_early_to_search = [] - - releases = fireEvent('release.for_media', media['_id'], single = True) - show_title = getTitle(show) - episode_identifier = '%s S%02d%s' % (show_title, season['info'].get('number', 0), "E%02d" % media['info'].get('number')) - - # Add parents - media['show'] = show - media['season'] = season - - index = 0 - for q_identifier in profile.get('qualities'): - quality_custom = { - 'quality': q_identifier, - 'finish': profile['finish'][index], - 'wait_for': profile['wait_for'][index], - '3d': profile['3d'][index] if profile.get('3d') else False - } - - has_better_quality = 0 - - # See if better quality is available - for release in releases: - if quality_order.index(release['quality']) <= quality_order.index(q_identifier) and release['status'] not in ['available', 'ignored', 'failed']: - has_better_quality += 1 - - # Don't search for quality lower then already available. - if has_better_quality is 0: - - log.info('Searching for %s in %s', (episode_identifier, q_identifier)) - quality = fireEvent('quality.single', identifier = q_identifier, single = True) - quality['custom'] = quality_custom - - results = fireEvent('searcher.search', search_protocols, media, quality, single = True) - if len(results) == 0: - log.debug('Nothing found for %s in %s', (episode_identifier, q_identifier)) - - # Add them to this movie releases list - found_releases += fireEvent('release.create_from_search', results, media, quality, single = True) - - # Try find a valid result and download it - if fireEvent('release.try_download_result', results, media, quality, manual, single = True): - ret = True - - # Remove releases that aren't found anymore - for release in releases: - if release.get('status') == 'available' and release.get('identifier') not in found_releases: - fireEvent('release.delete', release.get('id'), single = True) - else: - log.info('Better quality (%s) already available or snatched for %s', (q_identifier, episode_identifier)) - fireEvent('media.restatus', media['_id']) - break - - # Break if CP wants to shut down - if self.shuttingDown() or ret: - break - - if len(too_early_to_search) > 0: - log.info2('Too early to search for %s, %s', (too_early_to_search, episode_identifier)) - - def correctRelease(self, release = None, media = None, quality = None, **kwargs): - - if media.get('type') not in ['season', 'episode']: return - - retention = Env.setting('retention', section = 'nzb') - - if release.get('seeders') is None and 0 < retention < release.get('age', 0): - log.info2('Wrong: Outside retention, age is %s, needs %s or lower: %s', (release['age'], retention, release['name'])) - return False - - # Check for required and ignored words - if not fireEvent('searcher.correct_words', release['name'], media, single = True): - return False - - # TODO Matching is quite costly, maybe we should be caching release matches somehow? (also look at caper optimizations) - match = fireEvent('matcher.match', release, media, quality, single = True) - if match: - return match.weight - - return False - - def extendShow(self, media): - - db = get_db() - - seasons = db.get_many('media_children', media['_id'], with_doc = True) - - media['seasons'] = {} - - for sx in seasons: - season = sx['doc'] - - # Add episode info - season['episodes'] = {} - episodes = db.get_many('media_children', sx['_id'], with_doc = True) - - for se in episodes: - episode = se['doc'] - season['episodes'][episode['info'].get('number')] = episode - - # Add season to show - media['seasons'][season['info'].get('number', 0)] = season - - return media - - def searchAll(self): - pass - - def getSearchTitle(self, media): - # TODO: this should be done for season and episode - if media['type'] == 'season': - return getTitle(media) - elif media['type'] == 'episode': - return getTitle(media) diff --git a/couchpotato/core/media/show/searcher/__init__.py b/couchpotato/core/media/show/searcher/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/couchpotato/core/media/show/searcher/episode.py b/couchpotato/core/media/show/searcher/episode.py new file mode 100755 index 0000000..ea3a9db --- /dev/null +++ b/couchpotato/core/media/show/searcher/episode.py @@ -0,0 +1,152 @@ +from couchpotato import fireEvent, get_db, Env +from couchpotato.api import addApiView +from couchpotato.core.event import addEvent, fireEventAsync +from couchpotato.core.logger import CPLog +from couchpotato.core.media._base.searcher.base import SearcherBase +from couchpotato.core.media._base.searcher.main import SearchSetupError +from couchpotato.core.media.show import ShowTypeBase + +log = CPLog(__name__) + +autoload = 'EpisodeSearcher' + + +class EpisodeSearcher(SearcherBase, ShowTypeBase): + type = 'episode' + + in_progress = False + + def __init__(self): + super(EpisodeSearcher, self).__init__() + + addEvent('%s.searcher.all' % self.getType(), self.searchAll) + addEvent('%s.searcher.single' % self.getType(), self.single) + addEvent('searcher.correct_release', self.correctRelease) + + addApiView('%s.searcher.full_search' % self.getType(), self.searchAllView, docs = { + 'desc': 'Starts a full search for all wanted shows', + }) + + addApiView('%s.searcher.single' % self.getType(), self.singleView) + + def searchAllView(self, **kwargs): + fireEventAsync('%s.searcher.all' % self.getType(), manual = True) + + return { + 'success': not self.in_progress + } + + def searchAll(self, manual = False): + pass + + def singleView(self, media_id, **kwargs): + db = get_db() + media = db.get('id', media_id) + + return { + 'result': fireEvent('%s.searcher.single' % self.getType(), media, single = True) + } + + def single(self, media, profile = None, quality_order = None, search_protocols = None, manual = False): + db = get_db() + + related = fireEvent('library.related', media, single = True) + + # TODO search_protocols, profile, quality_order can be moved to a base method + # Find out search type + try: + if not search_protocols: + search_protocols = fireEvent('searcher.protocols', single = True) + except SearchSetupError: + return + + if not profile and related['show']['profile_id']: + profile = db.get('id', related['show']['profile_id']) + + if not quality_order: + quality_order = fireEvent('quality.order', single = True) + + # TODO: check episode status + # TODO: check air date + #if not self.conf('always_search') and not self.couldBeReleased(quality_type['quality']['identifier'] in pre_releases, release_dates, movie['library']['year']): + # too_early_to_search.append(quality_type['quality']['identifier']) + # return + + ret = False + has_better_quality = None + found_releases = [] + too_early_to_search = [] + + releases = fireEvent('release.for_media', media['_id'], single = True) + query = fireEvent('library.query', media, condense = False, single = True) + + index = 0 + for q_identifier in profile.get('qualities'): + quality_custom = { + 'quality': q_identifier, + 'finish': profile['finish'][index], + 'wait_for': profile['wait_for'][index], + '3d': profile['3d'][index] if profile.get('3d') else False + } + + has_better_quality = 0 + + # See if better quality is available + for release in releases: + if quality_order.index(release['quality']) <= quality_order.index(q_identifier) and release['status'] not in ['available', 'ignored', 'failed']: + has_better_quality += 1 + + # Don't search for quality lower then already available. + if has_better_quality is 0: + + log.info('Searching for %s in %s', (query, q_identifier)) + quality = fireEvent('quality.single', identifier = q_identifier, single = True) + quality['custom'] = quality_custom + + results = fireEvent('searcher.search', search_protocols, media, quality, single = True) + if len(results) == 0: + log.debug('Nothing found for %s in %s', (query, q_identifier)) + + # Add them to this movie releases list + found_releases += fireEvent('release.create_from_search', results, media, quality, single = True) + + # Try find a valid result and download it + if fireEvent('release.try_download_result', results, media, quality, single = True): + ret = True + + # Remove releases that aren't found anymore + for release in releases: + if release.get('status') == 'available' and release.get('identifier') not in found_releases: + fireEvent('release.delete', release.get('_id'), single = True) + else: + log.info('Better quality (%s) already available or snatched for %s', (q_identifier, query)) + fireEvent('media.restatus', media['_id']) + break + + # Break if CP wants to shut down + if self.shuttingDown() or ret: + break + + if len(too_early_to_search) > 0: + log.info2('Too early to search for %s, %s', (too_early_to_search, query)) + + def correctRelease(self, release = None, media = None, quality = None, **kwargs): + if media.get('type') != 'show.episode': + return + + retention = Env.setting('retention', section = 'nzb') + + if release.get('seeders') is None and 0 < retention < release.get('age', 0): + log.info2('Wrong: Outside retention, age is %s, needs %s or lower: %s', (release['age'], retention, release['name'])) + return False + + # Check for required and ignored words + if not fireEvent('searcher.correct_words', release['name'], media, single = True): + return False + + # TODO Matching is quite costly, maybe we should be caching release matches somehow? (also look at caper optimizations) + match = fireEvent('matcher.match', release, media, quality, single = True) + if match: + return match.weight + + return False diff --git a/couchpotato/core/media/show/searcher/season.py b/couchpotato/core/media/show/searcher/season.py new file mode 100755 index 0000000..c51d584 --- /dev/null +++ b/couchpotato/core/media/show/searcher/season.py @@ -0,0 +1,172 @@ +from couchpotato import get_db, Env +from couchpotato.api import addApiView +from couchpotato.core.event import addEvent, fireEventAsync, fireEvent +from couchpotato.core.logger import CPLog +from couchpotato.core.media._base.searcher.base import SearcherBase +from couchpotato.core.media.movie.searcher import SearchSetupError +from couchpotato.core.media.show import ShowTypeBase + +log = CPLog(__name__) + +autoload = 'SeasonSearcher' + + +class SeasonSearcher(SearcherBase, ShowTypeBase): + type = 'season' + + in_progress = False + + def __init__(self): + super(SeasonSearcher, self).__init__() + + addEvent('%s.searcher.all' % self.getType(), self.searchAll) + addEvent('%s.searcher.single' % self.getType(), self.single) + addEvent('searcher.correct_release', self.correctRelease) + + addApiView('%s.searcher.full_search' % self.getType(), self.searchAllView, docs = { + 'desc': 'Starts a full search for all wanted seasons', + }) + + def searchAllView(self, **kwargs): + fireEventAsync('%s.searcher.all' % self.getType(), manual = True) + + return { + 'success': not self.in_progress + } + + def searchAll(self, manual = False): + pass + + def single(self, media, profile = None, quality_order = None, search_protocols = None, manual = False): + db = get_db() + + related = fireEvent('library.related', media, single = True) + + # TODO search_protocols, profile, quality_order can be moved to a base method + # Find out search type + try: + if not search_protocols: + search_protocols = fireEvent('searcher.protocols', single = True) + except SearchSetupError: + return + + if not profile and related['show']['profile_id']: + profile = db.get('id', related['show']['profile_id']) + + if not quality_order: + quality_order = fireEvent('quality.order', single = True) + + # Find 'active' episodes + episodes = related['episodes'] + episodes_active = [] + + for episode in episodes: + if episode.get('status') != 'active': + continue + + episodes_active.append(episode) + + if len(episodes_active) == len(episodes): + # All episodes are 'active', try and search for full season + if self.search(media, profile, quality_order, search_protocols): + # Success, end season search + return True + else: + log.info('Unable to find season pack, searching for individual episodes...') + + # Search for each episode individually + for episode in episodes_active: + fireEvent('show.episode.searcher.single', episode, profile, quality_order, search_protocols, manual) + + # TODO (testing) only grab one episode + return True + + return True + + def search(self, media, profile, quality_order, search_protocols): + # TODO: check episode status + # TODO: check air date + #if not self.conf('always_search') and not self.couldBeReleased(quality_type['quality']['identifier'] in pre_releases, release_dates, movie['library']['year']): + # too_early_to_search.append(quality_type['quality']['identifier']) + # return + + ret = False + has_better_quality = None + found_releases = [] + too_early_to_search = [] + + releases = fireEvent('release.for_media', media['_id'], single = True) + query = fireEvent('library.query', media, condense = False, single = True) + + index = 0 + for q_identifier in profile.get('qualities'): + quality_custom = { + 'quality': q_identifier, + 'finish': profile['finish'][index], + 'wait_for': profile['wait_for'][index], + '3d': profile['3d'][index] if profile.get('3d') else False + } + + has_better_quality = 0 + + # See if better quality is available + for release in releases: + if quality_order.index(release['quality']) <= quality_order.index(q_identifier) and release['status'] not in ['available', 'ignored', 'failed']: + has_better_quality += 1 + + # Don't search for quality lower then already available. + if has_better_quality is 0: + + log.info('Searching for %s in %s', (query, q_identifier)) + quality = fireEvent('quality.single', identifier = q_identifier, single = True) + quality['custom'] = quality_custom + + results = fireEvent('searcher.search', search_protocols, media, quality, single = True) + if len(results) == 0: + log.debug('Nothing found for %s in %s', (query, q_identifier)) + + # Add them to this movie releases list + found_releases += fireEvent('release.create_from_search', results, media, quality, single = True) + + # Try find a valid result and download it + if fireEvent('release.try_download_result', results, media, quality, single = True): + ret = True + + # Remove releases that aren't found anymore + for release in releases: + if release.get('status') == 'available' and release.get('identifier') not in found_releases: + fireEvent('release.delete', release.get('_id'), single = True) + else: + log.info('Better quality (%s) already available or snatched for %s', (q_identifier, query)) + fireEvent('media.restatus', media['_id']) + break + + # Break if CP wants to shut down + if self.shuttingDown() or ret: + break + + if len(too_early_to_search) > 0: + log.info2('Too early to search for %s, %s', (too_early_to_search, query)) + + return len(found_releases) > 0 + + def correctRelease(self, release = None, media = None, quality = None, **kwargs): + if media.get('type') != 'show.season': + return + + retention = Env.setting('retention', section = 'nzb') + + if release.get('seeders') is None and 0 < retention < release.get('age', 0): + log.info2('Wrong: Outside retention, age is %s, needs %s or lower: %s', (release['age'], retention, release['name'])) + return False + + # Check for required and ignored words + if not fireEvent('searcher.correct_words', release['name'], media, single = True): + return False + + # TODO Matching is quite costly, maybe we should be caching release matches somehow? (also look at caper optimizations) + match = fireEvent('matcher.match', release, media, quality, single = True) + if match: + return match.weight + + return False diff --git a/couchpotato/core/media/show/searcher/show.py b/couchpotato/core/media/show/searcher/show.py new file mode 100755 index 0000000..b7619cc --- /dev/null +++ b/couchpotato/core/media/show/searcher/show.py @@ -0,0 +1,88 @@ +from couchpotato import get_db +from couchpotato.api import addApiView +from couchpotato.core.event import fireEvent, addEvent, fireEventAsync +from couchpotato.core.helpers.variable import getTitle +from couchpotato.core.logger import CPLog +from couchpotato.core.media._base.searcher.base import SearcherBase +from couchpotato.core.media._base.searcher.main import SearchSetupError +from couchpotato.core.media.show import ShowTypeBase + +log = CPLog(__name__) + +autoload = 'ShowSearcher' + + +class ShowSearcher(SearcherBase, ShowTypeBase): + type = 'show' + + in_progress = False + + def __init__(self): + super(ShowSearcher, self).__init__() + + addEvent('%s.searcher.all' % self.getType(), self.searchAll) + addEvent('%s.searcher.single' % self.getType(), self.single) + addEvent('searcher.get_search_title', self.getSearchTitle) + + addApiView('%s.searcher.full_search' % self.getType(), self.searchAllView, docs = { + 'desc': 'Starts a full search for all wanted episodes', + }) + + def searchAllView(self, **kwargs): + fireEventAsync('%s.searcher.all' % self.getType(), manual = True) + + return { + 'success': not self.in_progress + } + + def searchAll(self, manual = False): + pass + + def single(self, media, search_protocols = None, manual = False): + # Find out search type + try: + if not search_protocols: + search_protocols = fireEvent('searcher.protocols', single = True) + except SearchSetupError: + return + + if not media['profile_id'] or media['status'] == 'done': + log.debug('Show doesn\'t have a profile or already done, assuming in manage tab.') + return + + show_title = fireEvent('media.search_query', media, condense = False, single = True) + + fireEvent('notify.frontend', type = 'show.searcher.started.%s' % media['_id'], data = True, message = 'Searching for "%s"' % show_title) + + show_tree = fireEvent('library.tree', media, single = True) + + db = get_db() + + profile = db.get('id', media['profile_id']) + quality_order = fireEvent('quality.order', single = True) + + for season in show_tree.get('seasons', []): + if not season.get('info'): + continue + + # Skip specials (and seasons missing 'number') for now + # TODO: set status for specials to skipped by default + if not season['info'].get('number'): + continue + + # Check if full season can be downloaded + fireEvent('show.season.searcher.single', season, profile, quality_order, search_protocols, manual) + + # TODO (testing) only snatch one season + return + + fireEvent('notify.frontend', type = 'show.searcher.ended.%s' % media['_id'], data = True) + + def getSearchTitle(self, media): + if media.get('type') != 'show': + related = fireEvent('library.related', media, single = True) + show = related['show'] + else: + show = media + + return getTitle(show) diff --git a/couchpotato/core/media/show/season.py b/couchpotato/core/media/show/season.py deleted file mode 100644 index 9c6f8f0..0000000 --- a/couchpotato/core/media/show/season.py +++ /dev/null @@ -1,137 +0,0 @@ -from couchpotato import get_db -from couchpotato.core.event import addEvent, fireEvent, fireEventAsync -from couchpotato.core.logger import CPLog -from couchpotato.core.helpers.variable import tryInt -from couchpotato.core.media import MediaBase - - -log = CPLog(__name__) - -autoload = 'Season' - - -class Season(MediaBase): - - def __init__(self): - addEvent('media.search_query', self.query) - addEvent('media.identifier', self.identifier) - - addEvent('show.season.add', self.add) - addEvent('show.season.update_info', self.updateInfo) - - def add(self, parent_id, info = None, update_after = True): - if not info: info = {} - - identifiers = info.get('identifiers') - try: del info['identifiers'] - except: pass - try: del info['episodes'] - except: pass - - # Add Season - season_info = { - '_t': 'media', - 'type': 'season', - 'identifiers': identifiers, - 'parent_id': parent_id, - 'info': info, # Returned dict by providers - } - - # Check if season already exists - existing_season = fireEvent('media.with_identifiers', identifiers, with_doc = True, single = True) - - db = get_db() - - if existing_season: - s = existing_season['doc'] - s.update(season_info) - season = db.update(s) - else: - season = db.insert(season_info) - - # Update library info - if update_after is not False: - handle = fireEventAsync if update_after is 'async' else fireEvent - handle('show.season.update_info', season.get('_id'), info = info, single = True) - - return season - - def updateInfo(self, media_id = None, info = None, force = False): - if not info: info = {} - - if self.shuttingDown(): - return - - db = get_db() - - season = db.get('id', media_id) - - # Get new info - if not info: - info = fireEvent('season.info', season.get('identifiers'), merge = True) - - # Update/create media - if force: - - season['identifiers'].update(info['identifiers']) - if 'identifiers' in info: - del info['identifiers'] - - season.update({'info': info}) - s = db.update(season) - season.update(s) - - # Get images - image_urls = info.get('images', []) - existing_files = season.get('files', {}) - self.getPoster(image_urls, existing_files) - - return season - - def query(self, library, first = True, condense = True, include_identifier = True, **kwargs): - if library is list or library.get('type') != 'season': - return - - # Get the titles of the show - if not library.get('related_libraries', {}).get('show', []): - log.warning('Invalid library, unable to determine title.') - return - - titles = fireEvent( - 'media._search_query', - library['related_libraries']['show'][0], - first=False, - condense=condense, - - single=True - ) - - # Add season map_names if they exist - if 'map_names' in library['info']: - season_names = library['info']['map_names'].get(str(library['season_number']), {}) - - # Add titles from all locations - # TODO only add name maps from a specific location - for location, names in season_names.items(): - titles += [name for name in names if name and name not in titles] - - - identifier = fireEvent('media.identifier', library, single = True) - - # Add season identifier to titles - if include_identifier and identifier.get('season') is not None: - titles = [title + (' S%02d' % identifier['season']) for title in titles] - - - if first: - return titles[0] if titles else None - - return titles - - def identifier(self, library): - if library.get('type') != 'season': - return - - return { - 'season': tryInt(library['season_number'], None) - } diff --git a/couchpotato/core/plugins/dashboard.py b/couchpotato/core/plugins/dashboard.py old mode 100644 new mode 100755 diff --git a/couchpotato/core/plugins/quality/main.py b/couchpotato/core/plugins/quality/main.py index 3ed1a7a..aaaed9b 100644 --- a/couchpotato/core/plugins/quality/main.py +++ b/couchpotato/core/plugins/quality/main.py @@ -33,7 +33,25 @@ class QualityPlugin(Plugin): {'identifier': 'r5', 'size': (600, 1000), 'median_size': 700, 'label': 'R5', 'alternative': ['r6'], 'allow': ['dvdr', '720p', '1080p'], 'ext':[]}, {'identifier': 'tc', 'size': (600, 1000), 'median_size': 700, 'label': 'TeleCine', 'alternative': ['telecine'], 'allow': ['720p', '1080p'], 'ext':[]}, {'identifier': 'ts', 'size': (600, 1000), 'median_size': 700, 'label': 'TeleSync', 'alternative': ['telesync', 'hdts'], 'allow': ['720p', '1080p'], 'ext':[]}, - {'identifier': 'cam', 'size': (600, 1000), 'median_size': 700, 'label': 'Cam', 'alternative': ['camrip', 'hdcam'], 'allow': ['720p', '1080p'], 'ext':[]} + {'identifier': 'cam', 'size': (600, 1000), 'median_size': 700, 'label': 'Cam', 'alternative': ['camrip', 'hdcam'], 'allow': ['720p', '1080p'], 'ext':[]}, + + # TODO come back to this later, think this could be handled better, this is starting to get out of hand.... + # BluRay + {'identifier': 'bluray_1080p', 'hd': True, 'size': (800, 5000), 'label': 'BluRay - 1080p', 'width': 1920, 'height': 1080, 'alternative': [], 'allow': [], 'ext':['mkv']}, + {'identifier': 'bluray_720p', 'hd': True, 'size': (800, 5000), 'label': 'BluRay - 720p', 'width': 1280, 'height': 720, 'alternative': [], 'allow': [], 'ext':['mkv']}, + # BDRip + {'identifier': 'bdrip_1080p', 'hd': True, 'size': (800, 5000), 'label': 'BDRip - 1080p', 'width': 1920, 'height': 1080, 'alternative': [], 'allow': [], 'ext':['mkv']}, + {'identifier': 'bdrip_720p', 'hd': True, 'size': (800, 5000), 'label': 'BDRip - 720p', 'width': 1280, 'height': 720, 'alternative': [], 'allow': [], 'ext':['mkv']}, + # BRRip + {'identifier': 'brrip_1080p', 'hd': True, 'size': (800, 5000), 'label': 'BRRip - 1080p', 'width': 1920, 'height': 1080, 'alternative': [], 'allow': [], 'ext':['mkv']}, + {'identifier': 'brrip_720p', 'hd': True, 'size': (800, 5000), 'label': 'BRRip - 720p', 'width': 1280, 'height': 720, 'alternative': [], 'allow': [], 'ext':['mkv']}, + # WEB-DL + {'identifier': 'webdl_1080p', 'hd': True, 'size': (800, 5000), 'label': 'WEB-DL - 1080p', 'width': 1920, 'height': 1080, 'alternative': [], 'allow': [], 'ext':['mkv']}, + {'identifier': 'webdl_720p', 'hd': True, 'size': (800, 5000), 'label': 'WEB-DL - 720p', 'width': 1280, 'height': 720, 'alternative': [], 'allow': [], 'ext':['mkv']}, + {'identifier': 'webdl_480p', 'hd': True, 'size': (100, 5000), 'label': 'WEB-DL - 480p', 'width': 720, 'alternative': [], 'allow': [], 'ext':['mkv']}, + # HDTV + {'identifier': 'hdtv_720p', 'hd': True, 'size': (800, 5000), 'label': 'HDTV - 720p', 'width': 1280, 'height': 720, 'alternative': [], 'allow': [], 'ext':['mkv']}, + {'identifier': 'hdtv_sd', 'hd': False, 'size': (100, 1000), 'label': 'HDTV - SD', 'width': 720, 'alternative': [], 'allow': [], 'ext':['mkv', 'mp4', 'avi']}, ] pre_releases = ['cam', 'ts', 'tc', 'r5', 'scr'] threed_tags = { diff --git a/couchpotato/core/plugins/score/main.py b/couchpotato/core/plugins/score/main.py old mode 100644 new mode 100755 index e6fef25..f0e088c --- a/couchpotato/core/plugins/score/main.py +++ b/couchpotato/core/plugins/score/main.py @@ -1,4 +1,4 @@ -from couchpotato.core.event import addEvent +from couchpotato.core.event import addEvent, fireEvent from couchpotato.core.helpers.encoding import toUnicode from couchpotato.core.helpers.variable import getTitle, splitString, removeDuplicate from couchpotato.core.logger import CPLog @@ -16,17 +16,20 @@ class Score(Plugin): def __init__(self): addEvent('score.calculate', self.calculate) - def calculate(self, nzb, movie): + def calculate(self, nzb, media): """ Calculate the score of a NZB, used for sorting later """ + # Fetch root media item (movie, show) + root = fireEvent('library.root', media, single = True) + # Merge global and category preferred_words = splitString(Env.setting('preferred_words', section = 'searcher').lower()) - try: preferred_words = removeDuplicate(preferred_words + splitString(movie['category']['preferred'].lower())) + try: preferred_words = removeDuplicate(preferred_words + splitString(media['category']['preferred'].lower())) except: pass - score = nameScore(toUnicode(nzb['name']), movie['info']['year'], preferred_words) + score = nameScore(toUnicode(nzb['name']), root['info'].get('year'), preferred_words) - for movie_title in movie['info']['titles']: + for movie_title in root['info']['titles']: score += nameRatioScore(toUnicode(nzb['name']), toUnicode(movie_title)) score += namePositionScore(toUnicode(nzb['name']), toUnicode(movie_title)) @@ -44,15 +47,15 @@ class Score(Plugin): score += providerScore(nzb['provider']) # Duplicates in name - score += duplicateScore(nzb['name'], getTitle(movie)) + score += duplicateScore(nzb['name'], getTitle(root)) # Merge global and category ignored_words = splitString(Env.setting('ignored_words', section = 'searcher').lower()) - try: ignored_words = removeDuplicate(ignored_words + splitString(movie['category']['ignored'].lower())) + try: ignored_words = removeDuplicate(ignored_words + splitString(media['category']['ignored'].lower())) except: pass # Partial ignored words - score += partialIgnoredScore(nzb['name'], getTitle(movie), ignored_words) + score += partialIgnoredScore(nzb['name'], getTitle(root), ignored_words) # Ignore single downloads from multipart score += halfMultipartScore(nzb['name']) diff --git a/libs/tvdb_api/tvdb_api.py b/libs/tvdb_api/tvdb_api.py old mode 100644 new mode 100755 index 4bfe78a..87e8336 --- a/libs/tvdb_api/tvdb_api.py +++ b/libs/tvdb_api/tvdb_api.py @@ -705,7 +705,7 @@ class Tvdb: 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)) + #log().debug("Transforming %s to %s" % (k, new_key)) new_url = self.config['url_artworkPrefix'] % (v) banners[btype][btype2][bid][new_key] = new_url