diff --git a/couchpotato/core/media/movie/_base/main.py b/couchpotato/core/media/movie/_base/main.py index 20663b59..12eaf892 100644 --- a/couchpotato/core/media/movie/_base/main.py +++ b/couchpotato/core/media/movie/_base/main.py @@ -1,11 +1,17 @@ +import os from couchpotato import get_session from couchpotato.api import addApiView from couchpotato.core.event import fireEvent, fireEventAsync, addEvent from couchpotato.core.helpers.encoding import toUnicode -from couchpotato.core.helpers.variable import splitString, tryInt +from couchpotato.core.helpers.variable import getImdb, splitString, tryInt, \ + mergeDicts from couchpotato.core.logger import CPLog from couchpotato.core.media.movie import MovieTypeBase -from couchpotato.core.settings.model import Media +from couchpotato.core.settings.model import Library, LibraryTitle, Media, \ + Release +from sqlalchemy.orm import joinedload_all +from sqlalchemy.sql.expression import or_, asc, not_, desc +from string import ascii_lowercase import time log = CPLog(__name__) @@ -21,6 +27,28 @@ class MovieBase(MovieTypeBase): super(MovieBase, self).__init__() self.initType() + addApiView('movie.list', self.listView, docs = { + 'desc': 'List movies in wanted list', + 'params': { + 'status': {'type': 'array or csv', 'desc': 'Filter movie by status. Example:"active,done"'}, + 'release_status': {'type': 'array or csv', 'desc': 'Filter movie by status of its releases. Example:"snatched,available"'}, + 'limit_offset': {'desc': 'Limit and offset the movie list. Examples: "50" or "50,30"'}, + 'starts_with': {'desc': 'Starts with these characters. Example: "a" returns all movies starting with the letter "a"'}, + 'search': {'desc': 'Search movie title'}, + }, + 'return': {'type': 'object', 'example': """{ + 'success': True, + 'empty': bool, any movies returned or not, + 'movies': array, movies found, +}"""} + }) + addApiView('movie.get', self.getView, docs = { + 'desc': 'Get a movie by id', + 'params': { + 'id': {'desc': 'The id of the movie'}, + } + }) + addApiView('movie.available_chars', self.charView) addApiView('movie.add', self.addView, docs = { 'desc': 'Add new movie to the wanted list', 'params': { @@ -38,8 +66,256 @@ class MovieBase(MovieTypeBase): 'default_title': {'desc': 'Movie title to use for searches. Has to be one of the titles returned by movie.search.'}, } }) + addApiView('movie.delete', self.deleteView, docs = { + 'desc': 'Delete a movie from the wanted list', + 'params': { + 'id': {'desc': 'Movie ID(s) you want to delete.', 'type': 'int (comma separated)'}, + 'delete_from': {'desc': 'Delete movie from this page', 'type': 'string: all (default), wanted, manage'}, + 'with_files': {'desc': 'Delete the files as well', 'type': 'bool (true or false)'}, + } + }) addEvent('movie.add', self.add) + addEvent('movie.delete', self.delete) + addEvent('movie.get', self.get) + addEvent('movie.list', self.list) + addEvent('movie.restatus', self.restatus) + + def getView(self, id = None, **kwargs): + + movie = self.get(id) if id else None + + return { + 'success': movie is not None, + 'movie': movie, + } + + def get(self, movie_id): + + db = get_session() + + imdb_id = getImdb(str(movie_id)) + + if imdb_id: + m = db.query(Media).filter(Media.library.has(identifier = imdb_id)).first() + else: + m = db.query(Media).filter_by(id = movie_id).first() + + results = None + if m: + results = m.to_dict(self.default_dict) + + db.expire_all() + return results + + def list(self, status = None, release_status = None, limit_offset = None, starts_with = None, search = None, order = None): + + db = get_session() + + # Make a list from string + if status and not isinstance(status, (list, tuple)): + status = [status] + if release_status and not isinstance(release_status, (list, tuple)): + release_status = [release_status] + + # query movie ids + q = db.query(Media) \ + .with_entities(Media.id) \ + .group_by(Media.id) + + # Filter on movie status + if status and len(status) > 0: + statuses = fireEvent('status.get', status, single = len(status) > 1) + statuses = [s.get('id') for s in statuses] + + q = q.filter(Media.status_id.in_(statuses)) + + # Filter on release status + if release_status and len(release_status) > 0: + q = q.join(Media.releases) + + statuses = fireEvent('status.get', release_status, single = len(release_status) > 1) + statuses = [s.get('id') for s in statuses] + + q = q.filter(Release.status_id.in_(statuses)) + + # Only join when searching / ordering + if starts_with or search or order != 'release_order': + q = q.join(Media.library, Library.titles) \ + .filter(LibraryTitle.default == True) + + # Add search filters + filter_or = [] + if starts_with: + starts_with = toUnicode(starts_with.lower()) + if starts_with in ascii_lowercase: + filter_or.append(LibraryTitle.simple_title.startswith(starts_with)) + else: + ignore = [] + for letter in ascii_lowercase: + ignore.append(LibraryTitle.simple_title.startswith(toUnicode(letter))) + filter_or.append(not_(or_(*ignore))) + + if search: + filter_or.append(LibraryTitle.simple_title.like('%%' + search + '%%')) + + if len(filter_or) > 0: + q = q.filter(or_(*filter_or)) + + total_count = q.count() + if total_count == 0: + return 0, [] + + if order == 'release_order': + q = q.order_by(desc(Release.last_edit)) + else: + q = q.order_by(asc(LibraryTitle.simple_title)) + + if limit_offset: + splt = splitString(limit_offset) if isinstance(limit_offset, (str, unicode)) else limit_offset + limit = splt[0] + offset = 0 if len(splt) is 1 else splt[1] + q = q.limit(limit).offset(offset) + + # Get all movie_ids in sorted order + movie_ids = [m.id for m in q.all()] + + # List release statuses + releases = db.query(Release) \ + .filter(Release.movie_id.in_(movie_ids)) \ + .all() + + release_statuses = dict((m, set()) for m in movie_ids) + releases_count = dict((m, 0) for m in movie_ids) + for release in releases: + release_statuses[release.movie_id].add('%d,%d' % (release.status_id, release.quality_id)) + releases_count[release.movie_id] += 1 + + # Get main movie data + q2 = db.query(Media) \ + .options(joinedload_all('library.titles')) \ + .options(joinedload_all('library.files')) \ + .options(joinedload_all('status')) \ + .options(joinedload_all('files')) + + q2 = q2.filter(Media.id.in_(movie_ids)) + + results = q2.all() + + # Create dict by movie id + movie_dict = {} + for movie in results: + movie_dict[movie.id] = movie + + # List movies based on movie_ids order + movies = [] + for movie_id in movie_ids: + + releases = [] + for r in release_statuses.get(movie_id): + x = splitString(r) + releases.append({'status_id': x[0], 'quality_id': x[1]}) + + # Merge releases with movie dict + movies.append(mergeDicts(movie_dict[movie_id].to_dict({ + 'library': {'titles': {}, 'files':{}}, + 'files': {}, + }), { + 'releases': releases, + 'releases_count': releases_count.get(movie_id), + })) + + db.expire_all() + return total_count, movies + + def availableChars(self, status = None, release_status = None): + + status = status or [] + release_status = release_status or [] + + db = get_session() + + # Make a list from string + if not isinstance(status, (list, tuple)): + status = [status] + if release_status and not isinstance(release_status, (list, tuple)): + release_status = [release_status] + + q = db.query(Media) + + # Filter on movie status + if status and len(status) > 0: + statuses = fireEvent('status.get', status, single = len(release_status) > 1) + statuses = [s.get('id') for s in statuses] + + q = q.filter(Media.status_id.in_(statuses)) + + # Filter on release status + if release_status and len(release_status) > 0: + + statuses = fireEvent('status.get', release_status, single = len(release_status) > 1) + statuses = [s.get('id') for s in statuses] + + q = q.join(Media.releases) \ + .filter(Release.status_id.in_(statuses)) + + q = q.join(Library, LibraryTitle) \ + .with_entities(LibraryTitle.simple_title) \ + .filter(LibraryTitle.default == True) + + titles = q.all() + + chars = set() + for title in titles: + try: + char = title[0][0] + char = char if char in ascii_lowercase else '#' + chars.add(str(char)) + except: + log.error('Failed getting title for %s', title.libraries_id) + + if len(chars) == 25: + break + + db.expire_all() + return ''.join(sorted(chars)) + + def listView(self, **kwargs): + + status = splitString(kwargs.get('status')) + release_status = splitString(kwargs.get('release_status')) + limit_offset = kwargs.get('limit_offset') + starts_with = kwargs.get('starts_with') + search = kwargs.get('search') + order = kwargs.get('order') + + total_movies, movies = self.list( + status = status, + release_status = release_status, + limit_offset = limit_offset, + starts_with = starts_with, + search = search, + order = order + ) + + return { + 'success': True, + 'empty': len(movies) == 0, + 'total': total_movies, + 'movies': movies, + } + + def charView(self, **kwargs): + + status = splitString(kwargs.get('status', None)) + release_status = splitString(kwargs.get('release_status', None)) + chars = self.availableChars(status, release_status) + + return { + 'success': True, + 'empty': len(chars) == 0, + 'chars': chars, + } def add(self, params = None, force_readd = True, search_after = True, update_library = False, status_id = None): if not params: params = {} @@ -148,9 +424,9 @@ class MovieBase(MovieTypeBase): available_status = fireEvent('status.get', 'available', single = True) ids = splitString(id) - for media_id in ids: + for movie_id in ids: - m = db.query(Media).filter_by(id = media_id).first() + m = db.query(Media).filter_by(id = movie_id).first() if not m: continue @@ -173,12 +449,147 @@ class MovieBase(MovieTypeBase): db.commit() - fireEvent('media.restatus', m.id) + fireEvent('movie.restatus', m.id) movie_dict = m.to_dict(self.default_dict) - fireEventAsync('movie.searcher.single', movie_dict, on_complete = self.createNotifyFront(media_id)) + fireEventAsync('movie.searcher.single', movie_dict, on_complete = self.createNotifyFront(movie_id)) db.expire_all() return { 'success': True, } + + def deleteView(self, id = '', **kwargs): + + ids = splitString(id) + for movie_id in ids: + self.delete(movie_id, delete_from = kwargs.get('delete_from', 'all'), with_files = kwargs.get('with_files')) + + return { + 'success': True, + } + + def deleteFiles(self, instance): + directories = dict() + + # Walk through all files in the Couch database + for file_ in instance.files: + # Add the directories and filename prefixes to a list so we can + # remove the directories and related files as well + directory = os.path.dirname(file_.path) + if directory not in directories: + directories[directory] = set() + directories[directory].add(os.path.splitext(file_.path)[0]) + + if os.path.isfile(file_.path): + try: + os.remove(file_.path) + log.info('Removed %s', file_.path) + except: + log.error('Unable to remove %s', file_.path) + + # Walk through the directories and file prefixes for removal if + # possible + for directory, prefixes in directories.iteritems(): + if os.path.isdir(directory): + + # If the files in the directory have the same name as the + # expected files (except for extensions and stuff), remove them + files = os.listdir(directory) + for file_ in files: + for prefix in prefixes: + if file_.startswith(prefix): + try: + os.remove(file_) + print 'rmoeving', file_ + log.info('Removed %s', file_) + except: + log.error('Unable to remove %s', file_) + + try: + os.rmdir(directory) + log.info('Removed %s', directory) + except: + log.error('Unable to remove %s', directory) + + def delete(self, movie_id, delete_from = None, with_files = False): + + db = get_session() + + movie = db.query(Media).filter_by(id = movie_id).first() + if movie: + if with_files: + self.deleteFiles(movie) + + deleted = False + if delete_from == 'all': + db.delete(movie) + db.commit() + deleted = True + else: + done_status = fireEvent('status.get', 'done', single = True) + + total_releases = len(movie.releases) + total_deleted = 0 + new_movie_status = None + for release in movie.releases: + if with_files: + self.deleteFiles(release) + + if delete_from in ['wanted', 'snatched', 'late']: + if release.status_id != done_status.get('id'): + db.delete(release) + total_deleted += 1 + new_movie_status = 'done' + elif delete_from == 'manage': + if release.status_id == done_status.get('id'): + db.delete(release) + total_deleted += 1 + new_movie_status = 'active' + db.commit() + + if total_releases == total_deleted: + db.delete(movie) + db.commit() + deleted = True + elif new_movie_status: + new_status = fireEvent('status.get', new_movie_status, single = True) + movie.profile_id = None + movie.status_id = new_status.get('id') + db.commit() + else: + fireEvent('movie.restatus', movie.id, single = True) + + if deleted: + fireEvent('notify.frontend', type = 'movie.deleted', data = movie.to_dict()) + + db.expire_all() + return True + + def restatus(self, movie_id): + + active_status, done_status = fireEvent('status.get', ['active', 'done'], single = True) + + db = get_session() + + m = db.query(Media).filter_by(id = movie_id).first() + if not m or len(m.library.titles) == 0: + log.debug('Can\'t restatus movie, doesn\'t seem to exist.') + return False + + log.debug('Changing status for %s', m.library.titles[0].title) + if not m.profile: + m.status_id = done_status.get('id') + else: + move_to_wanted = True + + for t in m.profile.types: + for release in m.releases: + if t.quality.identifier is release.quality.identifier and (release.status_id is done_status.get('id') and t.finish): + move_to_wanted = False + + m.status_id = active_status.get('id') if move_to_wanted else done_status.get('id') + + db.commit() + + return True diff --git a/couchpotato/core/media/movie/_base/static/movie.actions.js b/couchpotato/core/media/movie/_base/static/movie.actions.js index 66c84c68..b81ab24f 100644 --- a/couchpotato/core/media/movie/_base/static/movie.actions.js +++ b/couchpotato/core/media/movie/_base/static/movie.actions.js @@ -431,7 +431,7 @@ MA.Release = new Class({ markMovieDone: function(){ var self = this; - Api.request('media.delete', { + Api.request('movie.delete', { 'data': { 'id': self.movie.get('id'), 'delete_from': 'wanted' @@ -450,7 +450,7 @@ MA.Release = new Class({ }, - tryNextRelease: function(){ + tryNextRelease: function(movie_id){ var self = this; Api.request('movie.searcher.try_next', { @@ -792,10 +792,27 @@ MA.Delete = new Class({ new Element('a.button.delete', { 'text': 'Delete ' + self.movie.title.get('text'), 'events': { - 'click': self.del.bind(self) + 'click': self.del.bind(self, false) } }) - ).inject(self.movie, 'top'); + ); + + /* Deleting files is only useful if it's already downloaded */ + if(self.movie.list.options.identifier == 'manage'){ + self.delete_container.adopt( + new Element('span', { + 'text': ' ' + }), + new Element('a.button.delete', { + 'text': '+ Files', + 'events': { + 'click': self.del.bind(self, true) + } + }) + ); + } + + self.delete_container.inject(self.movie, 'top'); } self.movie.slide('in', self.delete_container); @@ -810,7 +827,7 @@ MA.Delete = new Class({ self.movie.slide('out'); }, - del: function(e){ + del: function(withFiles, e){ (e).preventDefault(); var self = this; @@ -821,10 +838,11 @@ MA.Delete = new Class({ self.callChain(); }, function(){ - Api.request('media.delete', { + Api.request('movie.delete', { 'data': { 'id': self.movie.get('id'), - 'delete_from': self.movie.list.options.identifier + 'delete_from': self.movie.list.options.identifier, + 'with_files': !!withFiles }, 'onComplete': function(){ movie.set('tween', { @@ -840,7 +858,6 @@ MA.Delete = new Class({ ); self.callChain(); - } }); @@ -924,4 +941,4 @@ MA.Files = new Class({ self.movie.slide('in', self.options_container); }, -}); \ No newline at end of file +});