diff --git a/couchpotato/core/media/_base/library/main.py b/couchpotato/core/media/_base/library/main.py index da526ec..9e614fb 100755 --- a/couchpotato/core/media/_base/library/main.py +++ b/couchpotato/core/media/_base/library/main.py @@ -55,7 +55,7 @@ class Library(LibraryBase): ) def related(self, media): - result = {media['type']: media} + result = {self.key(media['type']): media} db = get_db() cur = media @@ -63,9 +63,17 @@ class Library(LibraryBase): while cur and cur.get('parent_id'): cur = db.get('id', cur['parent_id']) - parts = cur['type'].split('.') + result[self.key(cur['type'])] = cur - result[parts[-1]] = cur + children = db.get_many('media_children', media['_id'], with_doc = True) + + for item in children: + key = self.key(item['doc']['type']) + 's' + + if key not in result: + result[key] = [] + + result[key].append(item['doc']) return result @@ -94,8 +102,7 @@ class Library(LibraryBase): # Build children arrays for item in items: - parts = item['doc']['type'].split('.') - key = parts[-1] + 's' + key = self.key(item['doc']['type']) + 's' if key not in result: result[key] = {} @@ -115,3 +122,7 @@ class Library(LibraryBase): result['releases'] = fireEvent('release.for_media', result['_id'], single = True) return result + + def key(self, media_type): + parts = media_type.split('.') + return parts[-1] diff --git a/couchpotato/core/media/show/_base/main.py b/couchpotato/core/media/show/_base/main.py index 29af63a..41c7873 100755 --- 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, find from couchpotato.core.logger import CPLog from couchpotato.core.media import MediaBase -from qcond import QueryCondenser log = CPLog(__name__) @@ -110,7 +108,7 @@ class ShowBase(MediaBase): new = False try: - m = fireEvent('media.with_identifiers', params.get('identifiers'), with_doc = True, single = True)['doc'] + m = db.get('media', 'thetvdb-%s' % params.get('identifiers', {}).get('thetvdb'), with_doc = True)['doc'] except: new = True m = db.insert(media) @@ -155,7 +153,7 @@ class ShowBase(MediaBase): # Trigger update info if added and update_after: # Do full update to get images etc - fireEventAsync('show.update_extras', m, info, store = True, on_complete = onComplete) + fireEventAsync('show.update_extras', m.copy(), info, store = True, on_complete = onComplete) # Remove releases for rel in fireEvent('release.for_media', m['_id'], single = True): diff --git a/couchpotato/core/media/show/_base/static/episode.actions.js b/couchpotato/core/media/show/_base/static/episode.actions.js index fc2a0a2..cc46ce3 100755 --- a/couchpotato/core/media/show/_base/static/episode.actions.js +++ b/couchpotato/core/media/show/_base/static/episode.actions.js @@ -2,7 +2,7 @@ var EpisodeAction = new Class({ Implements: [Options], - class_name: 'action icon2', + class_name: 'item-action icon2', initialize: function(episode, options){ var self = this; diff --git a/couchpotato/core/media/show/_base/static/episode.js b/couchpotato/core/media/show/_base/static/episode.js index b7e73f3..92e49cc 100755 --- a/couchpotato/core/media/show/_base/static/episode.js +++ b/couchpotato/core/media/show/_base/static/episode.js @@ -14,7 +14,7 @@ var Episode = new Class({ self.profile = self.show.profile; - self.el = new Element('div.item').adopt( + self.el = new Element('div.item.episode').adopt( self.detail = new Element('div.item.data') ); @@ -34,14 +34,14 @@ var Episode = new Class({ self.quality = new Element('span.quality', { 'events': { 'click': function(e){ - var releases = self.detail.getElement('.episode-actions .releases'); + var releases = self.detail.getElement('.item-actions .releases'); if(releases.isVisible()) releases.fireEvent('click', [e]) } } }), - self.actions = new Element('div.episode-actions') + self.actions = new Element('div.item-actions') ); // Add profile 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 index 0b223b7..ec20240 100755 --- a/couchpotato/core/media/show/_base/static/show.css +++ b/couchpotato/core/media/show/_base/static/show.css @@ -616,7 +616,7 @@ .shows .options .table .item:nth-child(even) { background: rgba(255,255,255,0.05); } - .shows .options .table .item:not(.head):hover { + .shows .options .table .item:not(.head):not(.data):hover { background: rgba(255,255,255,0.03); } @@ -717,6 +717,10 @@ 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; @@ -734,6 +738,31 @@ 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; @@ -765,16 +794,44 @@ width: 85px; } - .shows .list .episodes .episode-actions { + .shows .list .episodes .item-actions { position: absolute; width: auto; right: 0; - top: 0; + + display: none; + opacity: 0; border-left: none; } - .shows .list .show .episodes .episode-actions .refresh { + .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; } diff --git a/couchpotato/core/media/show/_base/static/show.episodes.js b/couchpotato/core/media/show/_base/static/show.episodes.js index 5fa6645..c622e74 100755 --- a/couchpotato/core/media/show/_base/static/show.episodes.js +++ b/couchpotato/core/media/show/_base/static/show.episodes.js @@ -47,21 +47,9 @@ var Episodes = new Class({ createSeason: function(season) { var self = this, - title = ''; + s = new Season(self.show, self.options, season); - if(season.info.number) { - title = 'Season ' + season.info.number; - } else { - // Season 0 / Specials - title = 'Specials'; - } - - season['el'] = new Element('div', { - 'class': 'item head', - 'id': 'season_'+season._id - }).adopt( - new Element('span.name', {'text': title}) - ).inject(self.episodes_container); + $(s).inject(self.episodes_container); }, createEpisode: function(episode){ diff --git a/couchpotato/core/media/show/matcher.py b/couchpotato/core/media/show/matcher.py index 006b48a..4137c1c 100755 --- a/couchpotato/core/media/show/matcher.py +++ b/couchpotato/core/media/show/matcher.py @@ -107,6 +107,7 @@ class Episode(Base): return True + class Season(Base): type = 'show.season' @@ -121,7 +122,7 @@ class Season(Base): log.info2('Wrong: releases with identifier ranges are not supported yet') return False - required = fireEvent('media.identifier', media['library'], single = True) + required = fireEvent('library.identifier', media, single = True) if identifier != required: log.info2('Wrong: required identifier (%s) does not match release identifier (%s)', (required, identifier)) diff --git a/couchpotato/core/media/show/searcher/episode.py b/couchpotato/core/media/show/searcher/episode.py index 7e92537..ea3a9db 100755 --- a/couchpotato/core/media/show/searcher/episode.py +++ b/couchpotato/core/media/show/searcher/episode.py @@ -117,7 +117,7 @@ class EpisodeSearcher(SearcherBase, ShowTypeBase): # 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) + 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']) diff --git a/couchpotato/core/media/show/searcher/season.py b/couchpotato/core/media/show/searcher/season.py index b1fe630..c51d584 100755 --- a/couchpotato/core/media/show/searcher/season.py +++ b/couchpotato/core/media/show/searcher/season.py @@ -1,7 +1,9 @@ +from couchpotato import get_db, Env from couchpotato.api import addApiView -from couchpotato.core.event import addEvent, fireEventAsync +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__) @@ -19,6 +21,7 @@ class SeasonSearcher(SearcherBase, ShowTypeBase): 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', @@ -34,21 +37,136 @@ class SeasonSearcher(SearcherBase, ShowTypeBase): def searchAll(self, manual = False): pass - def single(self, media, show, profile): + 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 - # Check if any episode is already snatched - active = 0 - episodes = media.get('episodes', {}) - for ex in episodes: - episode = episodes.get(ex) + 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) - if episode.get('status') in ['active']: - active += 1 + # Find 'active' episodes + episodes = related['episodes'] + episodes_active = [] - if active != len(episodes): + 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 - # Try and search for full season - # TODO: + # 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 index 07d644b..b7619cc 100755 --- a/couchpotato/core/media/show/searcher/show.py +++ b/couchpotato/core/media/show/searcher/show.py @@ -54,36 +54,26 @@ class ShowSearcher(SearcherBase, ShowTypeBase): fireEvent('notify.frontend', type = 'show.searcher.started.%s' % media['_id'], data = True, message = 'Searching for "%s"' % show_title) - media = self.extendShow(media) + 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) - seasons = media.get('seasons', {}) - for sx in seasons: + for season in show_tree.get('seasons', []): + if not season.get('info'): + continue - # Skip specials for now TODO: set status for specials to skipped by default - if sx == 0: 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 - season = seasons.get(sx) + # Check if full season can be downloaded + fireEvent('show.season.searcher.single', season, profile, quality_order, search_protocols, manual) - # Check if full season can be downloaded TODO: add - season_success = fireEvent('show.season.searcher.single', season, media, profile) - - # Do each episode seperately - if not season_success: - episodes = season.get('episodes', {}) - for ex in episodes: - episode = episodes.get(ex) - - fireEvent('show.episode.searcher.single', episode, season, media, profile, quality_order, search_protocols) - - # TODO - return - - # TODO + # TODO (testing) only snatch one season return fireEvent('notify.frontend', type = 'show.searcher.ended.%s' % media['_id'], data = True) diff --git a/couchpotato/core/plugins/score/main.py b/couchpotato/core/plugins/score/main.py old mode 100644 new mode 100755 index 08a1855..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'].get('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'])