diff --git a/couchpotato/core/helpers/encoding.py b/couchpotato/core/helpers/encoding.py index 2a493b2..1f1fe16 100644 --- a/couchpotato/core/helpers/encoding.py +++ b/couchpotato/core/helpers/encoding.py @@ -13,11 +13,10 @@ def toSafeString(original): def simplifyString(original): - string = toSafeString(original) + string = toSafeString(' '.join(re.split('\W+', original.lower()))) split = re.split('\W+', string.lower()) return toUnicode(' '.join(split)) - def toUnicode(original, *args): try: if type(original) is unicode: diff --git a/couchpotato/core/plugins/file/main.py b/couchpotato/core/plugins/file/main.py index b85861e..8ed09d0 100644 --- a/couchpotato/core/plugins/file/main.py +++ b/couchpotato/core/plugins/file/main.py @@ -8,7 +8,9 @@ from couchpotato.core.plugins.base import Plugin from couchpotato.core.settings.model import FileType, File from couchpotato.environment import Env from flask.helpers import send_from_directory +from sqlalchemy.sql.expression import or_ import os.path +import traceback import urllib2 log = CPLog(__name__) @@ -51,15 +53,15 @@ class FileManager(Plugin): return dest - except Exception, e: - log.error('Unable to download file "%s": %s' % (url, e)) + except Exception: + log.error('Unable to download file "%s": %s' % (url, traceback.format_exc())) return False def add(self, path = '', part = 1, type = (), available = 1, properties = {}): db = get_session() - f = db.query(File).filter_by(path = toUnicode(path)).first() + f = db.query(File).filter(or_(File.path == toUnicode(path), File.path == path)).first() if not f: f = File() db.add(f) @@ -78,7 +80,6 @@ class FileManager(Plugin): def getType(self, type): db = get_session() - type, identifier = type ft = db.query(FileType).filter_by(identifier = identifier).first() diff --git a/couchpotato/core/plugins/file/static/file.js b/couchpotato/core/plugins/file/static/file.js index 48ef5be..68b1996 100644 --- a/couchpotato/core/plugins/file/static/file.js +++ b/couchpotato/core/plugins/file/static/file.js @@ -2,6 +2,11 @@ var File = new Class({ initialize: function(file){ var self = this; + + if(!file){ + self.el = new Element('div'); + return + } self.data = file; self.type = File.Type.get(file.type_id); diff --git a/couchpotato/core/plugins/library/main.py b/couchpotato/core/plugins/library/main.py index 6455972..28b9333 100644 --- a/couchpotato/core/plugins/library/main.py +++ b/couchpotato/core/plugins/library/main.py @@ -41,9 +41,7 @@ class LibraryPlugin(Plugin): if update_after: fireEventAsync('library.update', identifier = l.identifier, default_title = attrs.get('title', '')) - library_dict = l.to_dict() - - return library_dict + return l.to_dict({'titles': {}, 'files':{}}) def update(self, identifier, default_title = '', force = False): @@ -51,22 +49,27 @@ class LibraryPlugin(Plugin): library = db.query(Library).filter_by(identifier = identifier).first() done_status = fireEvent('status.get', 'done', single = True) + library_dict = library.to_dict({'titles': {}, 'files':{}}) + if library.status_id == done_status.get('id') and not force: - return + return library_dict info = fireEvent('provider.movie.info', merge = True, identifier = identifier) if not info or len(info) == 0: log.error('Could not update, no movie info to work with: %s' % identifier) - return + return library_dict # Main info library.plot = info.get('plot', '') library.tagline = info.get('tagline', '') library.year = info.get('year', 0) library.status_id = done_status.get('id') + db.commit() # Titles [db.delete(title) for title in library.titles] + db.commit() + titles = info.get('titles', []) log.debug('Adding titles: %s' % titles) @@ -83,6 +86,9 @@ class LibraryPlugin(Plugin): images = info.get('images', []) for type in images: for image in images[type]: + if not isinstance(image, str): + continue + file_path = fireEvent('file.download', url = image, single = True) file = fireEvent('file.add', path = file_path, type = ('image', type[:-1]), single = True) try: @@ -94,3 +100,5 @@ class LibraryPlugin(Plugin): #log.debug('Failed to attach to library: %s' % traceback.format_exc()) fireEvent('library.update.after') + + return library.to_dict({'titles': {}, 'files':{}}) diff --git a/couchpotato/core/plugins/movie/main.py b/couchpotato/core/plugins/movie/main.py index 8913abd..646c584 100644 --- a/couchpotato/core/plugins/movie/main.py +++ b/couchpotato/core/plugins/movie/main.py @@ -22,6 +22,9 @@ class MoviePlugin(Plugin): path = self.registerStatic(__file__) fireEvent('register_script', path + 'search.js') fireEvent('register_style', path + 'search.css') + fireEvent('register_script', path + 'movie.js') + fireEvent('register_style', path + 'movie.css') + fireEvent('register_script', path + 'list.js') def list(self): @@ -35,7 +38,7 @@ class MoviePlugin(Plugin): movies = [] for movie in results: temp = movie.to_dict(deep = { - 'releases': {'status': {}, 'quality': {}}, + 'releases': {'status': {}, 'quality': {}, 'files':{}}, 'library': {'titles': {}, 'files':{}}, 'files': {} }) @@ -62,7 +65,13 @@ class MoviePlugin(Plugin): if movie: #addEvent('library.update.after', ) - fireEventAsync('library.update', library = movie.library, default_title = default_title) + fireEventAsync('library.update', identifier = movie.library.identifier, default_title = default_title, force = True) + fireEventAsync('searcher.single', movie.to_dict(deep = { + 'profile': {'types': {'quality': {}}}, + 'releases': {'status': {}, 'quality': {}}, + 'library': {'titles': {}, 'files':{}}, + 'files': {} + })) return jsonified({ 'success': True, @@ -98,7 +107,7 @@ class MoviePlugin(Plugin): library = fireEvent('library.add', single = True, attrs = params) status = fireEvent('status.add', 'active', single = True) - m = db.query(Movie).filter_by(library_id = library.id).first() + m = db.query(Movie).filter_by(library_id = library.get('id')).first() if not m: m = Movie( library_id = library.get('id'), @@ -108,7 +117,7 @@ class MoviePlugin(Plugin): m.status_id = status.get('id') db.commit() - + movie_dict = m.to_dict(deep = { 'releases': {'status': {}, 'quality': {}}, 'library': {'titles': {}} @@ -121,7 +130,22 @@ class MoviePlugin(Plugin): }) def edit(self): - pass + + params = getParams() + db = get_session(); + + m = db.query(Movie).filter_by(id = params.get('id')).first() + m.profile_id = params.get('profile_id') + + # Default title + for title in m.library.titles: + title.default = params.get('default_title').lower() == title.title.lower() + + db.commit() + + return jsonified({ + 'success': True, + }) def delete(self): diff --git a/couchpotato/core/plugins/movie/static/list.js b/couchpotato/core/plugins/movie/static/list.js new file mode 100644 index 0000000..2facc39 --- /dev/null +++ b/couchpotato/core/plugins/movie/static/list.js @@ -0,0 +1,95 @@ +var MovieList = new Class({ + + Implements: [Options], + + options: { + navigation: true + }, + + movies: [], + + initialize: function(options){ + var self = this; + self.setOptions(options); + + self.el = new Element('div.movies'); + self.getMovies(); + }, + + create: function(){ + var self = this; + + // Create the alphabet nav + if(self.options.navigation) + self.createNavigation(); + + Object.each(self.movies, function(info){ + var m = new Movie(self, { + 'actions': self.options.actions + }, info); + $(m).inject(self.el); + m.fireEvent('injected'); + }); + + self.el.addEvents({ + 'mouseenter:relay(.movie)': function(e, el){ + el.addClass('hover') + }, + 'mouseleave:relay(.movie)': function(e, el){ + el.removeClass('hover') + } + }); + }, + + createNavigation: function(){ + var self = this; + var chars = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + var selected = 'Z'; + + self.navigation = new Element('div.alph_nav').adopt( + self.alpha = new Element('ul.inlay'), + self.input = new Element('input.inlay'), + self.view = new Element('ul.inlay').adopt( + new Element('li.list'), + new Element('li.thumbnails'), + new Element('li.text') + ) + ).inject(this.el, 'top') + + chars.split('').each(function(c){ + new Element('li', { + 'text': c, + 'class': c == selected ? 'selected' : '' + }).inject(self.alpha) + }) + + }, + + getMovies: function(status, onComplete){ + var self = this + + if(self.movies.length == 0) + Api.request('movie.list', { + 'data': { + 'status': self.options.status + }, + 'onComplete': function(json){ + self.store(json.movies); + self.create(); + } + }) + else + self.list() + }, + + store: function(movies){ + var self = this; + + self.movies = movies; + }, + + toElement: function(){ + return this.el; + } + +}); \ No newline at end of file diff --git a/couchpotato/core/plugins/movie/static/movie.css b/couchpotato/core/plugins/movie/static/movie.css new file mode 100644 index 0000000..70819a7 --- /dev/null +++ b/couchpotato/core/plugins/movie/static/movie.css @@ -0,0 +1,166 @@ +/* @override http://localhost:5000/static/movie_plugin/movie.css */ + +.movies { + padding: 20px 0; +} + + .movies .movie { + overflow: hidden; + position: relative; + + border-radius: 4px; + -moz-border-radius: 4px; + -webkit-border-radius: 4px; + margin: 10px 0; + } + .movies .movie:hover { + border-color: #ddd #fff #fff #ddd; + } + .movies .movie:hover .data { + } + .movies .data { + padding: 2%; + position: absolute; + width: 96.1%; + top: 0; + left: 0; + + border-radius: 4px; + -moz-border-radius: 4px; + -webkit-border-radius: 4px; + } + .movies .data:after { + content: ""; + display: block; + height: 0; + clear: both; + visibility: hidden; + } + .movies .data .poster, .options .poster { + overflow: hidden; + float: left; + max-width: 10%; + margin: 0 2% 0 0; + border-radius:3px; + -moz-border-radius: 3px; + -webkit-border-radius: 3px; + box-shadow: 0 0 10px rgba(0,0,0,0.35); + -moz-box-shadow: 0 0 10px rgba(0,0,0,0.35); + -webkit-box-shadow: 0 0 10px rgba(0,0,0,0.35); + line-height: 0; + } + + .movies .info { + float: right; + width: 88%; + } + + .movies .info .title { + font-size: 30px; + font-weight: bold; + margin-bottom: 10px; + float: left; + width: 80%; + } + + .movies .info .year { + font-size: 30px; + margin-bottom: 10px; + float: right; + color: #bbb; + width: 10%; + text-align: right; + } + + .movies .info .rating { + font-size: 30px; + margin-bottom: 10px; + color: #444; + float: left; + width: 5%; + padding: 0 0 0 3%; + background: url('../images/rating.png') no-repeat left center; + } + + .movies .info .description { + clear: both; + width: 95%; + } + .movies .data .actions { + position: absolute; + right: 15px; + bottom: 15px; + line-height: 0; + } + .movies .data:hover .action { opacity: 0.6; } + .movies .data:hover .action:hover { opacity: 1; } + + .movies .data .action { + background: no-repeat center; + display: inline-block; + width: 20px; + height: 20px; + padding: 3px; + opacity: 0; + } + .movies .data .action.refresh { background-image: url('../images/reload.png'); } + .movies .data .action.delete { background-image: url('../images/delete.png'); } + .movies .data .action.edit { background-image: url('../images/edit.png'); } + .movies .data .action.imdb { background-image: url('../images/imdb.png'); } + + .movies .delete_container { + clear: both; + text-align: center; + font-size: 20px; + position: relative; + } + .movies .delete_container .cancel { + } + .movies .delete_container .or { + padding: 10px; + } + .movies .delete_container .delete { + background-color: #ff321c; + font-weight: normal; + } + .movies .delete_container .delete:hover { + color: #fff; + background-color: #d32917; + } + + .movies .options .form { + margin-top: -2%; + float: left; + font-size: 20px; + } + + .movies .options .form select { + margin-right: 20px; + } + + .movies .options { + padding: 2%; + } + +.movies .alph_nav ul { + list-style: none; + padding: 0; + margin: 0; + + display: inline-block; +} + + .movies .alph_nav li { + display: inline-block; + vertical-align: top; + width: 24px; + height: 24px; + line-height: 26px; + text-align: center; + cursor: pointer; + margin: 0 -1px 0 0; + } + + .movies .alph_nav li:hover, .movies .alph_nav li.onlay { + font-weight: bold; + } \ No newline at end of file diff --git a/couchpotato/core/plugins/movie/static/movie.js b/couchpotato/core/plugins/movie/static/movie.js new file mode 100644 index 0000000..cabe5a4 --- /dev/null +++ b/couchpotato/core/plugins/movie/static/movie.js @@ -0,0 +1,176 @@ +var Movie = new Class({ + + Extends: BlockBase, + + action: {}, + + initialize: function(self, options, data){ + var self = this; + + self.data = data; + + self.profile = Quality.getProfile(data.profile_id); + self.parent(self, options); + self.addEvent('injected', self.afterInject.bind(self)) + }, + + create: function(){ + var self = this; + + self.el = new Element('div.movie.inlay').adopt( + self.data_container = new Element('div.data.inlay.light', { + 'tween': { + duration: 400, + transition: 'quint:in:out' + } + }).adopt( + self.thumbnail = File.Select.single('poster', self.data.library.files), + self.info_container = new Element('div.info').adopt( + self.title = new Element('div.title', { + 'text': self.getTitle() + }), + self.year = new Element('div.year', { + 'text': self.data.library.year || 'Unknown' + }), + self.rating = new Element('div.rating', { + 'text': self.data.library.rating + }), + self.description = new Element('div.description', { + 'text': self.data.library.plot + }), + self.quality = new Element('div.quality', { + 'text': self.profile ? self.profile.get('label') : '' + }) + ), + self.actions = new Element('div.actions') + ) + ); + + Object.each(self.options.actions, function(action, key){ + self.actions.adopt( + self.action[key.toLowerCase()] = new self.options.actions[key](self) + ) + }); + + if(!self.data.library.rating) + self.rating.hide(); + + }, + + afterInject: function(){ + var self = this; + + var height = self.getHeight(); + self.el.setStyle('height', height); + }, + + getTitle: function(){ + var self = this; + + var titles = self.data.library.titles; + + var title = titles.filter(function(title){ + return title['default'] + }).pop() + + if(title) + return title.title + else if(titles.length > 0) + return titles[0].title + + return 'Unknown movie' + }, + + slide: function(direction){ + var self = this; + + if(direction == 'in'){ + self.el.addEvent('outerClick', self.slide.bind(self, 'out')) + self.data_container.tween('left', 0, self.getWidth()); + } + else { + self.el.removeEvents('outerClick') + self.data_container.tween('left', self.getWidth(), 0); + } + }, + + getHeight: function(){ + var self = this; + + if(!self.height) + self.height = self.data_container.getCoordinates().height; + + return self.height; + }, + + getWidth: function(){ + var self = this; + + if(!self.width) + self.width = self.data_container.getCoordinates().width; + + return self.width; + }, + + get: function(attr){ + return this.data[attr] || this.data.library[attr] + } + +}); + +var MovieAction = new Class({ + + class_name: 'action', + + initialize: function(movie){ + var self = this; + self.movie = movie; + + self.create(); + self.el.addClass(self.class_name) + }, + + create: function(){}, + + disable: function(){ + this.el.addClass('disable') + }, + + enable: function(){ + this.el.removeClass('disable') + }, + + toElement: function(){ + return this.el + } + +}); + +var IMDBAction = new Class({ + + Extends: MovieAction, + id: null, + + create: function(){ + var self = this; + + self.id = self.movie.get('identifier'); + + self.el = new Element('a.imdb', { + 'title': 'Go to the IMDB page of ' + self.movie.getTitle(), + 'events': { + 'click': self.gotoIMDB.bind(self) + } + }); + + if(!self.id) self.disable(); + }, + + gotoIMDB: function(e){ + var self = this; + (e).stop(); + + window.open('http://www.imdb.com/title/'+self.id+'/'); + } + +}) \ No newline at end of file diff --git a/couchpotato/core/plugins/movie/static/search.css b/couchpotato/core/plugins/movie/static/search.css index 0d9551e..e94a449 100644 --- a/couchpotato/core/plugins/movie/static/search.css +++ b/couchpotato/core/plugins/movie/static/search.css @@ -1,4 +1,4 @@ -/* @override http://localhost:5000/static/style/plugin/movie_add.css */ +/* @override http://localhost:5000/static/movie_plugin/search.css */ .search_form { display: inline-block; @@ -7,52 +7,46 @@ .search_form input { padding-right: 25px; - border: 1px solid #aaa; padding: 4px; margin: 0; font-size: 14px; width: 90%; - - border-radius: 3px; - -webkit-border-radius: 3px; - -moz-border-radius: 3px; } .search_form .input a { - width: 12px; + width: 17px; height: 20px; display: inline-block; margin: 0 0 -5px -20px; top: 4px; right: 5px; - background: url('../../images/close_button.png') 0 center no-repeat; + background: url('../images/checks.png') right -36px no-repeat; cursor: pointer; } - .search_form .input a:hover { background-position: -12px center; } .search_form .results_container { padding: 10px 0; position: absolute; - background: #fff; - margin: 11px 0 0 -243px; + background: #5c697b; + margin: 6px 0 0 -246px; width: 470px; min-height: 140px; - - box-shadow: 0 0 30px rgba(0,0,0,0.2); - -moz-box-shadow: 0 0 30px rgba(0,0,0,0.2); - -webkit-box-shadow: 0 0 30px rgba(0,0,0,0.2); border-radius: 3px; -webkit-border-radius: 3px; -moz-border-radius: 3px; + + box-shadow: 0 0 50px rgba(0,0,0,0.55); + -moz-box-shadow: 0 0 50px rgba(0,0,0,0.55); + -webkit-box-shadow: 0 0 50px rgba(0,0,0,0.55); } .search_form .spinner { - background: #fff url('../../images/spinner.gif') no-repeat center 70px; + background: rgba(0,0,0,0.8) url('../images/spinner.gif') no-repeat center 70px; } .search_form .pointer { border-right: 10px solid transparent; border-left: 10px solid transparent; - border-bottom: 10px solid #fff; + border-bottom: 10px solid #5c697b; display: block; position: absolute; width: 0px; @@ -62,16 +56,24 @@ .search_form .results .movie { overflow: hidden; - background: #666; min-height: 140px; } .search_form .results .movie .options { - height: 140px; + height: 139px; + border: 1px solid transparent; + border-width: 1px 0; + border-radius: 0; + -webkit-border-radius: 0; + -moz-border-radius: 0; + box-shadow: inset 0 1px 8px rgba(0,0,0,0.25); + -moz-box-shadow: inset 0 1px 8px rgba(0,0,0,0.25); + -webkit-box-shadow: inset 0 1px 8px rgba(0,0,0,0.25); } .search_form .results .movie .options > div { padding: 0 15px; + border: 0; } .search_form .results .movie .options .thumbnail { @@ -105,11 +107,17 @@ position: relative; min-height: 100px; top: 0; - margin: -140px 0 0 0; - background: #fff; + margin: -143px 0 0 0; min-height: 140px; + background: #5c697b; + + border-bottom: 1px solid #333; + border-top: 1px solid rgba(255,255,255, 0.15); } + .search_form .results .movie:first-child .data { border-top: 0; } + .search_form .results .movie:last-child .data { border-bottom: 0; } + .search_form .results .movie .thumbnail { width: 17%; display: inline-block; @@ -129,7 +137,6 @@ display: inline-block; vertical-align: top; padding: 15px 0; - background: #fff; } .search_form .results .movie .add +.info { margin-left: 20%; diff --git a/couchpotato/core/plugins/movie/static/search.js b/couchpotato/core/plugins/movie/static/search.js index ce3a29f..c3cfc46 100644 --- a/couchpotato/core/plugins/movie/static/search.js +++ b/couchpotato/core/plugins/movie/static/search.js @@ -9,7 +9,7 @@ Block.Search = new Class({ self.el = new Element('div.search_form').adopt( new Element('div.input').adopt( - self.input = new Element('input', { + self.input = new Element('input.inlay', { 'events': { 'keyup': self.keyup.bind(self), 'focus': self.hideResults.bind(self, false) @@ -164,7 +164,7 @@ Block.Search.Item = new Class({ self.el = new Element('div.movie', { 'id': info.imdb }).adopt( - self.options = new Element('div.options'), + self.options = new Element('div.options.inlay'), self.data_container = new Element('div.data', { 'tween': { duration: 400, diff --git a/couchpotato/core/plugins/profile/main.py b/couchpotato/core/plugins/profile/main.py index 48fb67a..7f8d9eb 100644 --- a/couchpotato/core/plugins/profile/main.py +++ b/couchpotato/core/plugins/profile/main.py @@ -1,6 +1,6 @@ from couchpotato import get_session from couchpotato.api import addApiView -from couchpotato.core.event import addEvent +from couchpotato.core.event import addEvent, fireEvent from couchpotato.core.helpers.request import jsonified, getParams, getParam from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin @@ -17,6 +17,10 @@ class ProfilePlugin(Plugin): addApiView('profile.save', self.save) addApiView('profile.delete', self.delete) + path = self.registerStatic(__file__) + fireEvent('register_script', path + 'profile.js') + fireEvent('register_style', path + 'profile.css') + def all(self): db = get_session() @@ -59,7 +63,7 @@ class ProfilePlugin(Plugin): order += 1 db.commit() - + profile_dict = p.to_dict(deep = {'types': {}}) return jsonified({ diff --git a/couchpotato/core/plugins/profile/static/profile.css b/couchpotato/core/plugins/profile/static/profile.css new file mode 100644 index 0000000..ab6ef98 --- /dev/null +++ b/couchpotato/core/plugins/profile/static/profile.css @@ -0,0 +1,18 @@ +.profile > .delete { + background-position: center; + height: 20px; + width: 20px; +} + +.profile .types .type .handle { + background: url('../../images/handle.png') center; + display: inline-block; + height: 20px; + width: 20px; +} + +.profile .types .type .delete { + background-position: center; + height: 20px; + width: 20px; +} \ No newline at end of file diff --git a/couchpotato/core/plugins/profile/static/profile.js b/couchpotato/core/plugins/profile/static/profile.js new file mode 100644 index 0000000..080f971 --- /dev/null +++ b/couchpotato/core/plugins/profile/static/profile.js @@ -0,0 +1,259 @@ +var Profile = new Class({ + + data: {}, + types: [], + + initialize: function(data){ + var self = this; + + self.data = data; + self.types = []; + + self.create(); + + self.el.addEvents({ + 'change:relay(select, input[type=checkbox])': self.save.bind(self, 0), + 'keyup:relay(input[type=text])': self.save.bind(self, [300]) + }); + + }, + + create: function(){ + var self = this; + + var data = self.data; + + self.el = new Element('div.profile').adopt( + self.header = new Element('h4', {'text': data.label}), + new Element('span.delete.icon', { + 'events': { + 'click': self.del.bind(self) + } + }), + new Element('div', { + 'class': 'ctrlHolder' + }).adopt( + new Element('label', {'text':'Name'}), + new Element('input.label.textInput.large', { + 'type':'text', + 'value': data.label, + 'events': { + 'keyup': function(){ + self.header.set('text', this.get('value')) + } + } + }) + ), + new Element('div.ctrlHolder').adopt( + new Element('label', {'text':'Wait'}), + new Element('input.wait_for.textInput.xsmall', { + 'type':'text', + 'value': data.types && data.types.length > 0 ? data.types[0].wait_for : 0 + }), + new Element('span', {'text':' day(s) for better quality.'}) + ), + new Element('div.ctrlHolder').adopt( + new Element('label', {'text': 'Qualities'}), + new Element('div.head').adopt( + new Element('span.quality_type', {'text': 'Search for'}), + new Element('span.finish', {'html': 'Finish'}) + ), + self.type_container = new Element('ol.types'), + new Element('a.addType', { + 'text': 'Add another quality to search for.', + 'href': '#', + 'events': { + 'click': self.addType.bind(self) + } + }) + ) + ); + + self.makeSortable() + + if(data.types) + Object.each(data.types, self.addType.bind(self)) + }, + + save: function(delay){ + var self = this; + + if(self.save_timer) clearTimeout(self.save_timer); + self.save_timer = (function(){ + + var data = self.getData(); + if(data.types.length < 2) return; + + Api.request('profile.save', { + 'data': self.getData(), + 'useSpinner': true, + 'spinnerOptions': { + 'target': self.el + }, + 'onComplete': function(json){ + if(json.success){ + self.data = json.profile + } + } + }); + }).delay(delay, self) + + }, + + getData: function(){ + var self = this; + + var data = { + 'id' : self.data.id, + 'label' : self.el.getElement('.label').get('value'), + 'wait_for' : self.el.getElement('.wait_for').get('value'), + 'types': [] + } + + Array.each(self.type_container.getElements('.type'), function(type){ + if(!type.hasClass('deleted')) + data.types.include({ + 'quality_id': type.getElement('select').get('value'), + 'finish': +type.getElement('input[type=checkbox]').checked + }); + }) + + return data + }, + + addType: function(data){ + var self = this; + + var t = new Profile.Type(data); + $(t).inject(self.type_container); + self.sortable.addItems($(t)); + + self.types.include(t); + + }, + + del: function(){ + var self = this; + + if(!confirm('Are you sure you want to delete this profile?')) return + + Api.request('profile.delete', { + 'data': { + 'id': self.data.id + }, + 'useSpinner': true, + 'spinnerOptions': { + 'target': self.el + }, + 'onComplete': function(json){ + if(json.success) + self.el.destroy(); + else + alert(json.message) + } + }); + }, + + makeSortable: function(){ + var self = this; + + self.sortable = new Sortables(self.type_container, { + 'revert': true, + //'clone': true, + 'handle': '.handle', + 'opacity': 0.5, + 'onComplete': self.save.bind(self, 300) + }); + }, + + get: function(attr){ + return this.data[attr] + }, + + isCore: function(){ + return this.data.core + }, + + toElement: function(){ + return this.el + } + +}); + +Profile.Type = Class({ + + deleted: false, + + initialize: function(data){ + var self = this; + + self.data = data; + self.create(); + + }, + + create: function(){ + var self = this; + var data = self.data; + + self.el = new Element('li.type').adopt( + new Element('span.quality_type').adopt( + self.fillQualities() + ), + new Element('span.finish').adopt( + self.finish = new Element('input', { + 'type':'checkbox', + 'class':'finish', + 'checked': data.finish + }) + ), + new Element('span.delete.icon', { + 'events': { + 'click': self.del.bind(self) + } + }), + new Element('span.handle') + ) + + }, + + fillQualities: function(){ + var self = this; + + self.qualities = new Element('select'); + + Object.each(Quality.qualities, function(q){ + new Element('option', { + 'text': q.label, + 'value': q.id + }).inject(self.qualities) + }); + + self.qualities.set('value', self.data.quality_id); + + return self.qualities; + + }, + + getData: function(){ + var self = this; + + return { + 'quality_id': self.qualities.get('value'), + 'finish': +self.finish.checked + } + }, + + del: function(){ + var self = this; + + self.el.addClass('deleted'); + self.el.hide(); + self.deleted = true; + }, + + toElement: function(){ + return this.el; + } + +}) \ No newline at end of file diff --git a/couchpotato/core/plugins/quality/main.py b/couchpotato/core/plugins/quality/main.py index e81369e..b537784 100644 --- a/couchpotato/core/plugins/quality/main.py +++ b/couchpotato/core/plugins/quality/main.py @@ -4,6 +4,8 @@ from couchpotato.core.helpers.encoding import toUnicode from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin from couchpotato.core.settings.model import Quality, Profile, ProfileType +import os.path +import re log = CPLog(__name__) @@ -11,10 +13,10 @@ log = CPLog(__name__) class QualityPlugin(Plugin): qualities = [ - {'identifier': 'bd50', 'size': (15000, 60000), 'label': 'BR-Disk', 'alternative': ['1080p', 'bd25'], 'allow': [], 'ext':[], 'tags': ['x264', 'h264', 'blu ray']}, - {'identifier': '1080p', 'size': (5000, 20000), 'label': '1080P', 'alternative': [], 'allow': [], 'ext':['mkv', 'm2ts'], 'tags': ['x264', 'h264', 'bluray']}, - {'identifier': '720p', 'size': (3500, 10000), 'label': '720P', 'alternative': [], 'allow': [], 'ext':['mkv', 'm2ts'], 'tags': ['x264', 'h264', 'bluray']}, - {'identifier': 'brrip', 'size': (700, 7000), 'label': 'BR-Rip', 'alternative': ['bdrip'], 'allow': ['720p'], 'ext':['mkv', 'avi']}, + {'identifier': 'bd50', 'size': (15000, 60000), 'label': 'BR-Disk', 'width': 1920, 'alternative': ['1080p', 'bd25'], 'allow': [], 'ext':[], 'tags': ['x264', 'h264', 'blu ray']}, + {'identifier': '1080p', 'size': (5000, 20000), 'label': '1080P', 'width': 1920, 'alternative': [], 'allow': [], 'ext':['mkv', 'm2ts'], 'tags': ['x264', 'h264', 'bluray']}, + {'identifier': '720p', 'size': (3500, 10000), 'label': '720P', 'width': 1280, 'alternative': [], 'allow': [], 'ext':['mkv', 'm2ts'], 'tags': ['x264', 'h264', 'bluray']}, + {'identifier': 'brrip', 'size': (700, 7000), 'label': 'BR-Rip', 'alternative': ['bdrip'], 'allow': ['720p'], 'ext':['avi']}, {'identifier': 'dvdr', 'size': (3000, 10000), 'label': 'DVD-R', 'alternative': [], 'allow': [], 'ext':['iso', 'img'], 'tags': ['pal', 'ntsc']}, {'identifier': 'dvdrip', 'size': (600, 2400), 'label': 'DVD-Rip', 'alternative': [], 'allow': [], 'ext':['avi', 'mpg', 'mpeg']}, {'identifier': 'scr', 'size': (600, 1600), 'label': 'Screener', 'alternative': ['dvdscr'], 'allow': ['dvdr'], 'ext':['avi', 'mpg', 'mpeg']}, @@ -28,6 +30,7 @@ class QualityPlugin(Plugin): def __init__(self): addEvent('quality.all', self.all) addEvent('quality.single', self.single) + addEvent('quality.guess', self.guess) addEvent('app.load', self.fill) path = self.registerStatic(__file__) @@ -50,9 +53,11 @@ class QualityPlugin(Plugin): def single(self, identifier = ''): db = get_session() + quality_dict = {} quality = db.query(Quality).filter_by(identifier = identifier).first() - quality_dict = dict(self.getQuality(quality.identifier), **quality.to_dict()) + if quality: + quality_dict = dict(self.getQuality(quality.identifier), **quality.to_dict()) return quality_dict @@ -110,3 +115,41 @@ class QualityPlugin(Plugin): db.commit() return True + + def guess(self, files, extra = {}): + found = False + + for file in files: + size = (os.path.getsize(file) / 1024 / 1024) + words = re.split('\W+', file.lower()) + for quality in self.all(): + correctSize = False + + if size >= quality['size_min'] and size <= quality['size_max']: + correctSize = True + + # Check tags + if type in words: + found = True + + for alt in quality.get('alternative'): + if alt in words: + found = True + + for tag in quality.get('tags', []): + if tag in words: + found = True + + # Check extension + filesize + for ext in quality.get('ext'): + if ext in words and correctSize: + found = True + + # Last check on resolution only + if quality.get('width', 480) == extra.get('resolution_width', 0): + found = True + + if found: + return quality + + return '' diff --git a/couchpotato/core/plugins/quality/static/quality.css b/couchpotato/core/plugins/quality/static/quality.css index c4abc1d..e69de29 100644 --- a/couchpotato/core/plugins/quality/static/quality.css +++ b/couchpotato/core/plugins/quality/static/quality.css @@ -1,21 +0,0 @@ -/* @override http://localhost:5000/static/style/plugin/quality.css */ - - -.profile > .delete { - background-position: center; - height: 20px; - width: 20px; -} - -.profile .types .type .handle { - background: url('../../images/handle.png') center; - display: inline-block; - height: 20px; - width: 20px; -} - -.profile .types .type .delete { - background-position: center; - height: 20px; - width: 20px; -} \ No newline at end of file diff --git a/couchpotato/core/plugins/quality/static/quality.js b/couchpotato/core/plugins/quality/static/quality.js index 9dd82d4..1d9382f 100644 --- a/couchpotato/core/plugins/quality/static/quality.js +++ b/couchpotato/core/plugins/quality/static/quality.js @@ -33,7 +33,6 @@ var QualityBase = new Class({ self.content = tab.content; self.createProfiles(); - self.createOrdering(); self.createSizes(); }) @@ -84,290 +83,35 @@ var QualityBase = new Class({ }, /** - * Ordering - */ - createOrdering: function(){ - var self = this; - - self.settings.createGroup({ - 'label': 'Order', - 'description': 'Discriptions' - }).inject(self.content) - - }, - - /** * Sizes */ createSizes: function(){ var self = this; - self.settings.createGroup({ + var group = self.settings.createGroup({ 'label': 'Sizes', 'description': 'Discriptions', 'advanced': true }).inject(self.content) - } - -}); -window.Quality = new QualityBase(); - -var Profile = new Class({ - - data: {}, - types: [], - - initialize: function(data){ - var self = this; - - self.data = data; - self.types = []; - - self.create(); - - self.el.addEvents({ - 'change:relay(select, input[type=checkbox])': self.save.bind(self, 0), - 'keyup:relay(input[type=text])': self.save.bind(self, [300]) - }); - - }, - - create: function(){ - var self = this; - - var data = self.data; - - self.el = new Element('div.profile').adopt( - self.header = new Element('h4', {'text': data.label}), - new Element('span.delete.icon', { - 'events': { - 'click': self.del.bind(self) - } - }), - new Element('div', { - 'class': 'ctrlHolder' - }).adopt( - new Element('label', {'text':'Name'}), - new Element('input.label.textInput.large', { - 'type':'text', - 'value': data.label, - 'events': { - 'keyup': function(){ - self.header.set('text', this.get('value')) - } - } - }) - ), - new Element('div.ctrlHolder').adopt( - new Element('label', {'text':'Wait'}), - new Element('input.wait_for.textInput.xsmall', { - 'type':'text', - 'value': data.types && data.types.length > 0 ? data.types[0].wait_for : 0 - }), - new Element('span', {'text':' day(s) for better quality.'}) - ), - new Element('div.ctrlHolder').adopt( - new Element('label', {'text': 'Qualities'}), - new Element('div.head').adopt( - new Element('span.quality_type', {'text': 'Search for'}), - new Element('span.finish', {'html': 'Finish'}) - ), - self.type_container = new Element('ol.types'), - new Element('a.addType', { - 'text': 'Add another quality to search for.', - 'href': '#', - 'events': { - 'click': self.addType.bind(self) - } - }) - ) - ); - - self.makeSortable() - - if(data.types) - Object.each(data.types, self.addType.bind(self)) - }, - - save: function(delay){ - var self = this; - - if(self.save_timer) clearTimeout(self.save_timer); - self.save_timer = (function(){ - - var data = self.getData(); - if(data.types.length < 2) return; - - Api.request('profile.save', { - 'data': self.getData(), - 'useSpinner': true, - 'spinnerOptions': { - 'target': self.el - }, - 'onComplete': function(json){ - if(json.success){ - self.data = json.profile - } - } - }); - }).delay(delay, self) - - }, - - getData: function(){ - var self = this; - - var data = { - 'id' : self.data.id, - 'label' : self.el.getElement('.label').get('value'), - 'wait_for' : self.el.getElement('.wait_for').get('value'), - 'types': [] - } - - Array.each(self.type_container.getElements('.type'), function(type){ - if(!type.hasClass('deleted')) - data.types.include({ - 'quality_id': type.getElement('select').get('value'), - 'finish': +type.getElement('input[type=checkbox]').checked - }); + + new Element('div.item.header').adopt( + new Element('span.label', {'text': 'Quality'}), + new Element('span.min', {'text': 'Min'}), + new Element('span.max', {'text': 'Max'}) + ).inject(group) + + Object.each(self.qualities, function(quality){ + new Element('div.item').adopt( + new Element('span.label', {'text': quality.label}), + new Element('input.min', {'value': quality.size_min}), + new Element('input.max', {'value': quality.size_max}) + ).inject(group) }) - - return data - }, - - addType: function(data){ - var self = this; - - var t = new Profile.Type(data); - $(t).inject(self.type_container); - self.sortable.addItems($(t)); - - self.types.include(t); - - }, - - del: function(){ - var self = this; - - if(!confirm('Are you sure you want to delete this profile?')) return - - Api.request('profile.delete', { - 'data': { - 'id': self.data.id - }, - 'useSpinner': true, - 'spinnerOptions': { - 'target': self.el - }, - 'onComplete': function(json){ - if(json.success) - self.el.destroy(); - else - alert(json.message) - } - }); - }, - - makeSortable: function(){ - var self = this; - - self.sortable = new Sortables(self.type_container, { - 'revert': true, - //'clone': true, - 'handle': '.handle', - 'opacity': 0.5, - 'onComplete': self.save.bind(self, 300) - }); - }, - - get: function(attr){ - return this.data[attr] - }, - - isCore: function(){ - return this.data.core - }, - - toElement: function(){ - return this.el + + p(group) + } }); -Profile.Type = Class({ - - deleted: false, - - initialize: function(data){ - var self = this; - - self.data = data; - self.create(); - - }, - - create: function(){ - var self = this; - var data = self.data; - - self.el = new Element('li.type').adopt( - new Element('span.quality_type').adopt( - self.fillQualities() - ), - new Element('span.finish').adopt( - self.finish = new Element('input', { - 'type':'checkbox', - 'class':'finish', - 'checked': data.finish - }) - ), - new Element('span.delete.icon', { - 'events': { - 'click': self.del.bind(self) - } - }), - new Element('span.handle') - ) - - }, - - fillQualities: function(){ - var self = this; - - self.qualities = new Element('select'); - - Object.each(Quality.qualities, function(q){ - new Element('option', { - 'text': q.label, - 'value': q.id - }).inject(self.qualities) - }); - - self.qualities.set('value', self.data.quality_id); - - return self.qualities; - - }, - - getData: function(){ - var self = this; - - return { - 'quality_id': self.qualities.get('value'), - 'finish': +self.finish.checked - } - }, - - del: function(){ - var self = this; - - self.el.addClass('deleted'); - self.el.hide(); - self.deleted = true; - }, - - toElement: function(){ - return this.el; - } - -}) +window.Quality = new QualityBase(); diff --git a/couchpotato/core/plugins/renamer/__init__.py b/couchpotato/core/plugins/renamer/__init__.py index 5d8aa22..07ad234 100644 --- a/couchpotato/core/plugins/renamer/__init__.py +++ b/couchpotato/core/plugins/renamer/__init__.py @@ -8,9 +8,8 @@ config = [{ 'groups': [ { 'tab': 'renamer', - 'name': 'tmdb', - 'label': 'TheMovieDB', - 'advanced': True, + 'name': 'renamer', + 'label': 'Folders', 'description': 'Move and rename your downloaded movies to your movie directory.', 'options': [ { @@ -29,12 +28,52 @@ config = [{ 'description': 'Folder where the movies will be moved to.', }, { + 'name': 'folder_name', + 'label': 'Folder naming', + 'description': 'Name of the folder', + }, + { + 'name': 'file_name', + 'label': 'File naming', + 'description': 'Name of the file', + }, + { + 'advanced': True, + 'name': 'separator', + 'label': 'Separator', + 'description': 'Replace all the spaces with a character. Example: ".", "-". Leave empty to use spaces.', + }, + { + 'advanced': True, 'name': 'run_every', 'label': 'Run every', 'default': 1, 'type': 'int', 'unit': 'min(s)', 'description': 'Search for new movies inside the folder every X minutes.', + }, + ], + }, { + 'tab': 'renamer', + 'name': 'meta_renamer', + 'label': 'Advanced renaming', + 'description': 'Meta data file renaming. Use <filename> to use the above "File naming" settings, without the file extention.', + 'advanced': True, + 'options': [ + { + 'name': 'trailer_name', + 'label': 'Trailer naming', + 'default': '-trailer.', + }, + { + 'name': 'nfo_name', + 'label': 'NFO naming', + 'default': '.', + }, + { + 'name': 'backdrop_name', + 'label': 'Backdrop naming', + 'default': '-backdrop.', } ], }, diff --git a/couchpotato/core/plugins/renamer/main.py b/couchpotato/core/plugins/renamer/main.py index 30ee786..45f456a 100644 --- a/couchpotato/core/plugins/renamer/main.py +++ b/couchpotato/core/plugins/renamer/main.py @@ -1,6 +1,14 @@ +from couchpotato import get_session from couchpotato.core.event import addEvent, fireEvent +from couchpotato.core.helpers.encoding import toUnicode +from couchpotato.core.helpers.variable import getExt from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin +from couchpotato.core.settings.model import Library +import os.path +import re +import shutil +import traceback log = CPLog(__name__) @@ -8,12 +16,208 @@ log = CPLog(__name__) class Renamer(Plugin): def __init__(self): - pass addEvent('renamer.scan', self.scan) - addEvent('app.load', self.scan) + #addEvent('app.load', self.scan) #fireEvent('schedule.interval', 'renamer.scan', self.scan, minutes = self.conf('run_every')) def scan(self): - pass + + groups = fireEvent('scanner.scan', folder = self.conf('from'), single = True) + + destination = self.conf('to') + folder_name = self.conf('folder_name') + file_name = self.conf('file_name') + trailer_name = self.conf('trailer_name') + backdrop_name = self.conf('fanart_name') + nfo_name = self.conf('nfo_name') + separator = self.conf('separator') + + for group_identifier in groups: + + group = groups[group_identifier] + rename_files = {} + + # Add _UNKNOWN_ if no library is connected + if not group['library']: + if group['dirname']: + rename_files[group['parentdir']] = group['parentdir'].replace(group['dirname'], '_UNKNOWN_%s' % group['dirname']) + else: # Add it to filename + for file_type in group['files']: + for rename_me in group['files'][file_type]: + filename = os.path.basename(rename_me) + rename_files[rename_me] = rename_me.replace(filename, '_UNKNOWN_%s' % filename) + + # Rename the files using the library data + else: + group['library'] = fireEvent('library.update', identifier = group['library']['identifier'], single = True) + library = group['library'] + + # Find subtitle for renaming + fireEvent('renamer.before', group) + + # Remove weird chars from moviename + movie_name = re.sub(r"[\x00\/\\:\*\?\"<>\|]", '', group['library']['titles'][0]['title']) + + # Put 'The' at the end + name_the = movie_name + if movie_name[:4].lower() == 'the ': + name_the = movie_name[4:] + ', The' + + replacements = { + 'ext': 'mkv', + 'namethe': name_the.strip(), + 'thename': movie_name.strip(), + 'year': library['year'], + 'first': name_the[0].upper(), + 'dirname': group['dirname'], + 'quality': group['meta_data']['quality']['label'], + 'quality_type': group['meta_data']['quality_type'], + 'video': group['meta_data'].get('video'), + 'audio': group['meta_data'].get('audio'), + 'group': group['meta_data']['group'], + 'source': group['meta_data']['source'], + 'resolution_width': group['meta_data'].get('resolution_width'), + 'resolution_height': group['meta_data'].get('resolution_height'), + } + + for file_type in group['files']: + + # Move DVD files (no renaming) + if group['is_dvd'] and file_type is 'movie': + continue + + # Move nfo depending on settings + if file_type is 'nfo' and not self.conf('rename_nfo'): + continue + + # Subtitle extra + if file_type is 'subtitle_extra': + continue + + # Move other files + multiple = len(group['files']['movie']) > 1 + cd = 1 if multiple else 0 + + for file in sorted(list(group['files'][file_type])): + + # Original filename + replacements['original'] = os.path.basename(file) + + # Extension + replacements['ext'] = getExt(file) + + # cd # + replacements['cd'] = ' cd%d' % cd if cd else '' + replacements['cd_nr'] = cd + + # Naming + final_folder_name = self.doReplace(folder_name, replacements) + final_file_name = self.doReplace(file_name, replacements) + replacements['filename'] = final_file_name[:-(len(getExt(final_file_name)) + 1)] + + # Meta naming + if file_type is 'trailer': + final_file_name = self.doReplace(trailer_name, replacements) + elif file_type is 'nfo': + final_file_name = self.doReplace(nfo_name, replacements) + elif file_type is 'backdrop': + final_file_name = self.doReplace(backdrop_name, replacements) + + # Seperator replace + if separator: + final_file_name = final_file_name.replace(' ', separator) + + # Main file + rename_files[file] = os.path.join(destination, final_folder_name, final_file_name) + + # Check for extra subtitle files + if file_type is 'subtitle': + + def test(s): + return file[:-len(replacements['ext'])] in s + + for subtitle_extra in set(filter(test, group['files']['subtitle_extra'])): + replacements['ext'] = getExt(subtitle_extra) + + final_folder_name = self.doReplace(folder_name, replacements) + final_file_name = self.doReplace(file_name, replacements) + rename_files[subtitle_extra] = os.path.join(destination, final_folder_name, final_file_name) + + if multiple: + cd += 1 + + # Before renaming, remove the lower quality files + db = get_session() + library = db.query(Library).filter_by(identifier = group['library']['identifier']).first() + for movie in library.movies: + for release in movie.releases: + if release.quality.order < group['meta_data']['quality']['order']: + log.info('Removing older release for %s, with quality %s' % (movie.library.titles[0].title, release.quality.label)) + elif release.quality.order is group['meta_data']['quality']['order']: + log.info('Same quality release already exists for %s, with quality %s. Assuming repack.' % (movie.library.titles[0].title, release.quality.label)) + else: + log.info('Better quality release already exists for %s, with quality %s' % (movie.library.titles[0].title, release.quality.label)) + + # Add _EXISTS_ to the parent dir + if group['dirname']: + for rename_me in rename_files: # Don't rename anything in this group + rename_files[rename_me] = None + rename_files[group['parentdir']] = group['parentdir'].replace(group['dirname'], '_EXISTS_%s' % group['dirname']) + else: # Add it to filename + for rename_me in rename_files: + filename = os.path.basename(rename_me) + rename_files[rename_me] = rename_me.replace(filename, '_EXISTS_%s' % filename) + + break + + for file in release.files: + log.info('Removing "%s"' % file.path) + + # Rename + for rename_me in rename_files: + if rename_files[rename_me]: + log.info('Renaming "%s" to "%s"' % (rename_me, rename_files[rename_me])) + + path = os.path.dirname(rename_files[rename_me]) + try: + if not os.path.isdir(path): os.makedirs(path) + except: + log.error('Failed creating dir %s: %s' % (path, traceback.format_exc())) + continue + + #print rename_me, rename_files[rename_me] + + # Search for trailers + fireEvent('renamer.after', group) + + def moveFile(self, old, dest, suppress = True): + try: + shutil.move(old, dest) + except: + log.error("Couldn't move file '%s' to '%s': %s" % (old, dest, traceback.format_exc())) + return False + + return True + + def doReplace(self, string, replacements): + ''' + replace confignames with the real thing + ''' + + replaced = toUnicode(string) + for x, r in replacements.iteritems(): + if r is not None: + replaced = replaced.replace('<%s>' % toUnicode(x), toUnicode(r)) + else: + #If information is not available, we don't want the tag in the filename + replaced = replaced.replace('<' + x + '>', '') + + replaced = re.sub(r"[\x00:\*\?\"<>\|]", '', replaced) + + sep = self.conf('separator') + return self.replaceDoubles(replaced).replace(' ', ' ' if not sep else sep) + + def replaceDoubles(self, string): + return string.replace(' ', ' ').replace(' .', '.') diff --git a/couchpotato/core/plugins/scanner/main.py b/couchpotato/core/plugins/scanner/main.py index 2859ab1..caae1ce 100644 --- a/couchpotato/core/plugins/scanner/main.py +++ b/couchpotato/core/plugins/scanner/main.py @@ -1,10 +1,10 @@ from couchpotato import get_session from couchpotato.core.event import fireEvent, addEvent -from couchpotato.core.helpers.encoding import toUnicode +from couchpotato.core.helpers.encoding import toUnicode, simplifyString from couchpotato.core.helpers.variable import getExt from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin -from couchpotato.core.settings.model import File, Library, Release, Movie +from couchpotato.core.settings.model import File, Release, Movie from couchpotato.environment import Env from flask.helpers import json from themoviedb.tmdb import opensubtitleHashFile @@ -23,7 +23,7 @@ class Scanner(Plugin): 'trailer': 1048576, # 1MB } ignored_in_path = ['_unpack', '_failed_', '_unknown_', '_exists_', '.appledouble', '.appledb', '.appledesktop', os.path.sep + '._', '.ds_store', 'cp.cpnfo'] #unpacking, smb-crap, hidden files - ignore_names = ['extract', 'extracting', 'extracted', 'movie', 'movies', 'film', 'films'] + ignore_names = ['extract', 'extracting', 'extracted', 'movie', 'movies', 'film', 'films', 'download', 'downloads'] extensions = { 'movie': ['mkv', 'wmv', 'avi', 'mpg', 'mpeg', 'mp4', 'm2ts', 'iso', 'img'], 'dvd': ['vts_*', 'vob'], @@ -42,7 +42,7 @@ class Scanner(Plugin): codecs = { 'audio': ['dts', 'ac3', 'ac3d', 'mp3'], - 'video': ['x264', 'divx', 'xvid'] + 'video': ['x264', 'h264', 'divx', 'xvid'] } source_media = { @@ -52,12 +52,16 @@ class Scanner(Plugin): 'hdtv': ['hdtv'] } - clean = '(?i)[^\s](ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multisubs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|r3|r5|bd5|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|hrhd|hrhdtv|hddvd|bluray|x264|h264|xvid|xvidvd|xxx|www.www|cd[1-9]|\[.*\])[^\s]*' + clean = '[ _\,\.\(\)\[\]\-](french|swedisch|danish|dutch|swesub|spanish|german|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multisubs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|r3|r5|bd5|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|video_ts|audio_ts|480p|480i|576p|576i|720p|720i|1080p|1080i|hrhd|hrhdtv|hddvd|bluray|x264|h264|xvid|xvidvd|xxx|www.www|cd[1-9]|\[.*\])([ _\,\.\(\)\[\]\-]|$)' multipart_regex = [ '[ _\.-]+cd[ _\.-]*([0-9a-d]+)', #*cd1 '[ _\.-]+dvd[ _\.-]*([0-9a-d]+)', #*dvd1 - '[ _\.-]+part[ _\.-]*([0-9a-d]+)', #*part1.mkv - '[ _\.-]+dis[ck][ _\.-]*([0-9a-d]+)', #*disk1.mkv + '[ _\.-]+part[ _\.-]*([0-9a-d]+)', #*part1 + '[ _\.-]+dis[ck][ _\.-]*([0-9a-d]+)', #*disk1 + 'cd[ _\.-]*([0-9a-d]+)$', #cd1.ext + 'dvd[ _\.-]*([0-9a-d]+)$', #dvd1.ext + 'part[ _\.-]*([0-9a-d]+)$', #part1.mkv + 'dis[ck][ _\.-]*([0-9a-d]+)$', #disk1.mkv '()[ _\.-]+([0-9]*[abcd]+)(\.....?)$', '([a-z])([0-9]+)(\.....?)$', '()([ab])(\.....?)$' #*a.mkv @@ -65,42 +69,54 @@ class Scanner(Plugin): def __init__(self): - addEvent('app.load', self.scan) + #addEvent('app.load', self.scanLibrary) - def scan(self, folder = '/Volumes/Media/Test/'): + addEvent('scanner.scan', self.scan) - """ - Get all files + def scanLibrary(self): - For each file larger then 350MB - create movie "group", this is where all movie files will be grouped - group multipart together - check if its DVD (VIDEO_TS) + folder = '/Volumes/Media/Test/' - # This should work for non-folder based structure - for each moviegroup + groups = self.scan(folder = folder) - for each file smaller then 350MB, allfiles.filter(moviename*) + # Open up the db + db = get_session() - # Assuming the beginning of the filename is the same for this structure - Movie is masterfile, moviename-cd1.ext -> moviename - Find other files connected to moviename, moviename*.nfo, moviename*.sub, moviename*trailer.ext + # Mark all files as "offline" before a adding them to the database (again) + files_in_path = db.query(File).filter(File.path.like(toUnicode(folder) + u'%%')) + files_in_path.update({'available': 0}, synchronize_session = False) + db.commit() - Remove found file from allfiles + update_after = [] + for group in groups.itervalues(): - # This should work for folder based structure - for each leftover file - Loop over leftover files, use dirname as moviename + # Save to DB + if group['library']: + #library = db.query(Library).filter_by(id = library.get('id')).one() + # Add release + self.addRelease(group) - For each found movie + # Add identifier for library update + update_after.append(group['library'].get('identifier')) - determine filetype + for identifier in update_after: + fireEvent('library.update', identifier = identifier) - Check if it's already in the db + # If cleanup option is enabled, remove offline files from database + if self.conf('cleanup_offline'): + files_in_path = db.query(File).filter(File.path.like(folder + '%%')).filter_by(available = 0) + [db.delete(x) for x in files_in_path] + db.commit() + + db.remove() - Add it to database - """ + + def scan(self, folder = None): + + if not folder or not os.path.isdir(folder): + log.error('Folder doesn\'t exists: %s' % folder) + return {} # Get movie "master" files movie_files = {} @@ -153,22 +169,16 @@ class Scanner(Plugin): # Remove the found files from the leftover stack leftovers = leftovers - found_files - # Open up the db - db = get_session() - - # Mark all files as "offline" before a adding them to the database (again) - files_in_path = db.query(File).filter(File.path.like(toUnicode(folder) + u'%%')) - files_in_path.update({'available': 0}, synchronize_session = False) - db.commit() # Determine file types - update_after = [] - for identifier, group in movie_files.iteritems(): + for identifier in movie_files: + group = movie_files[identifier] # Group extra (and easy) files first images = self.getImages(group['unsorted_files']) group['files'] = { 'subtitle': self.getSubtitles(group['unsorted_files']), + 'subtitle_extra': self.getSubtitlesExtras(group['unsorted_files']), 'nfo': self.getNfo(group['unsorted_files']), 'trailer': self.getTrailers(group['unsorted_files']), 'backdrop': images['backdrop'], @@ -180,7 +190,22 @@ class Scanner(Plugin): group['files']['movie'] = self.getDVDFiles(group['unsorted_files']) else: group['files']['movie'] = self.getMediaFiles(group['unsorted_files']) - group['meta_data'] = self.getMetaData(group['files']['movie']) + group['meta_data'] = self.getMetaData(group) + + # Get parent dir from movie files + for movie_file in group['files']['movie']: + group['parentdir'] = os.path.dirname(movie_file) + group['dirname'] = None + + folders = group['parentdir'].replace(folder, '').split(os.path.sep) + + # Try and get a proper dirname, so no "A", "Movie", "Download" + for folder in folders: + if folder.lower() in self.ignore_names or len(folder) < 2: + group['dirname'] = folder + break + + break # Leftover "sorted" files for type in group['files']: @@ -191,34 +216,16 @@ class Scanner(Plugin): # Determine movie group['library'] = self.determineMovie(group) + if not group['library']: + log.error('Unable to determin movie: %s' % group['identifiers']) - # Save to DB - if group['library']: - #library = db.query(Library).filter_by(id = library.get('id')).one() - - # Add release - release = self.addRelease(group) - return - - # Add identifier for library update - update_after.append(group['library'].get('identifier')) - - for identifier in update_after: - fireEvent('library.update', identifier = identifier) - - # If cleanup option is enabled, remove offline files from database - if self.conf('cleanup_offline'): - files_in_path = db.query(File).filter(File.path.like(folder + '%%')).filter_by(available = 0) - [db.delete(x) for x in files_in_path] - db.commit() - - db.remove() + return movie_files def addRelease(self, group): db = get_session() - identifier = '%s.%s.%s' % (group['library']['identifier'], group['meta_data']['audio'], group['meta_data']['quality']) + identifier = '%s.%s.%s' % (group['library']['identifier'], group['meta_data'].get('audio', 'unknown'), group['meta_data']['quality']['identifier']) # Add movie done_status = fireEvent('status.get', 'done', single = True) @@ -233,13 +240,13 @@ class Scanner(Plugin): db.commit() # Add release - quality = fireEvent('quality.single', group['meta_data']['quality'], single = True) release = db.query(Release).filter_by(identifier = identifier).first() if not release: + release = Release( identifier = identifier, movie = movie, - quality_id = quality.get('id'), + quality_id = group['meta_data']['quality'].get('id'), status_id = done_status.get('id') ) db.add(release) @@ -259,18 +266,36 @@ class Scanner(Plugin): db.remove() - def getMetaData(self, files): + def getMetaData(self, group): - return { - 'audio': 'AC3', - 'quality': '720p', - 'quality_type': 'HD', - 'resolution_width': 1280, - 'resolution_height': 720 - } + data = {} + files = group['files']['movie'] for file in files: - self.getMeta(file) + if os.path.getsize(file) < self.minimal_filesize['media']: continue # Ignore smaller files + + meta = self.getMeta(file) + + try: + data['video'] = self.getCodec(file, self.codecs['video']) + data['audio'] = meta['audio stream'][0]['compression'] + data['resolution_width'] = meta['video stream'][0]['image width'] + data['resolution_height'] = meta['video stream'][0]['image height'] + except: + pass + + if data.get('audio'): break + + data['quality'] = fireEvent('quality.guess', files = files, extra = data, single = True) + if not data['quality']: + data['quality'] = fireEvent('quality.single', 'dvdr' if group['is_dvd'] else 'dvdrip', single = True) + + data['quality_type'] = 'HD' if data.get('resolution_width', 0) >= 720 else 'SD' + + data['group'] = self.getGroup(file[0]) + data['source'] = self.getSourceMedia(file[0]) + + return data def getMeta(self, filename): lib_dir = os.path.join(Env.get('app_dir'), 'libs') @@ -281,40 +306,58 @@ class Scanner(Plugin): try: meta = json.loads(z) - log.info('Retrieved metainfo: %s' % meta) return meta - except Exception, e: - print e - log.error('Couldn\'t get metadata from file') + except Exception: + log.error('Couldn\'t get metadata from file: %s' % traceback.format_exc()) def determineMovie(self, group): imdb_id = None files = group['files'] - # Check and see if nfo contains the imdb-id - try: - for nfo_file in files['nfo']: - imdb_id = self.getImdb(nfo_file) - if imdb_id: break - except: - pass - # Check if path is already in db - db = get_session() + # Check for CP(imdb_id) string in the file paths for file in files['movie']: - f = db.query(File).filter_by(path = toUnicode(file)).first() + imdb_id = self.getCPImdb(file) + if imdb_id: break + + # Check and see if nfo contains the imdb-id + if not imdb_id: try: - imdb_id = f.library[0].identifier - break + for nfo_file in files['nfo']: + imdb_id = self.getImdb(nfo_file) + if imdb_id: break except: pass - db.remove() + + # Check if path is already in db + if not imdb_id: + db = get_session() + for file in files['movie']: + f = db.query(File).filter_by(path = toUnicode(file)).first() + try: + imdb_id = f.library[0].identifier + break + except: + pass + db.remove() + + # Search based on OpenSubtitleHash + if not imdb_id and not group['is_dvd']: + for file in files['movie']: + movie = fireEvent('provider.movie.by_hash', file = file, merge = True) + + if len(movie) > 0: + imdb_id = movie[0]['imdb'] + if imdb_id: break # Search based on identifiers if not imdb_id: for identifier in group['identifiers']: + if len(identifier) > 2: + movie = fireEvent('provider.movie.search', q = identifier, merge = True, limit = 1) + if len(movie) > 0: imdb_id = movie[0]['imdb'] if imdb_id: break @@ -329,7 +372,7 @@ class Scanner(Plugin): }, update_after = False, single = True) log.error('No imdb_id found for %s.' % group['identifiers']) - return False + return {} def saveFile(self, file, type = 'unknown', include_media_info = False): @@ -342,6 +385,17 @@ class Scanner(Plugin): # Check database and update/insert if necessary return fireEvent('file.add', path = file, part = self.getPartNumber(file), type = self.file_types[type], properties = properties, single = True) + def getCPImdb(self, string): + + try: + m = re.search('(cp\((?Ptt[0-9{7}]+)\))', string.lower()) + id = m.group('id') + if id: return id + except AttributeError: + pass + + return False + def getImdb(self, txt): if os.path.isfile(txt): @@ -375,6 +429,9 @@ class Scanner(Plugin): def getSubtitles(self, files): return set(filter(lambda s: getExt(s.lower()) in self.extensions['subtitle'], files)) + def getSubtitlesExtras(self, files): + return set(filter(lambda s: getExt(s.lower()) in self.extensions['subtitle_extra'], files)) + def getNfo(self, files): return set(filter(lambda s: getExt(s.lower()) in self.extensions['nfo'], files)) @@ -447,20 +504,42 @@ class Scanner(Plugin): return set(filter(lambda s:identifier in self.createFileIdentifier(s, folder), file_pile)) def createFileIdentifier(self, file_path, folder, exclude_filename = False): + identifier = file_path.replace(folder, '') # root folder identifier = os.path.splitext(identifier)[0] # ext + if exclude_filename: identifier = identifier[:len(identifier) - len(os.path.split(identifier)[-1])] - identifier = self.removeMultipart(identifier) # multipart - return identifier + # multipart + identifier = self.removeMultipart(identifier) + + # groups, release tags, scenename cleaner, regex isn't correct + identifier = re.sub(self.clean, '::', simplifyString(identifier)) + + year = self.findYear(identifier) + if year: + identifier = '%s %s' % (identifier.split(year)[0].strip(), year) + else: + identifier = identifier.split('::')[0] + + # Remove duplicates + out = [] + for word in identifier.split(): + if not word in out: + out.append(word) + + identifier = ' '.join(out) + + return simplifyString(identifier) + def removeMultipart(self, name): for regex in self.multipart_regex: try: found = re.sub(regex, '', name) if found != name: - return found + name = found except: pass return name @@ -475,3 +554,33 @@ class Scanner(Plugin): except: pass return name + + def getCodec(self, filename, codecs): + codecs = map(re.escape, codecs) + try: + codec = re.search('[^A-Z0-9](?P' + '|'.join(codecs) + ')[^A-Z0-9]', filename, re.I) + return (codec and codec.group('codec')) or '' + except: + return '' + + def getGroup(self, file): + try: + group = re.search('-(?P[A-Z0-9]+)$', file, re.I) + return group.group('group') or '' + except: + return '' + + def getSourceMedia(self, file): + for media in self.source_media: + for alias in self.source_media[media]: + if alias in file.lower(): + return media + + return None + + def findYear(self, text): + matches = re.search('(?P[0-9]{4})', text) + if matches: + return matches.group('year') + + return '' diff --git a/couchpotato/core/plugins/searcher/main.py b/couchpotato/core/plugins/searcher/main.py index fa77161..d2d8372 100644 --- a/couchpotato/core/plugins/searcher/main.py +++ b/couchpotato/core/plugins/searcher/main.py @@ -19,7 +19,7 @@ class Searcher(Plugin): # Schedule cronjob fireEvent('schedule.cron', 'searcher.all', self.all, day = self.conf('cron_day'), hour = self.conf('cron_hour'), minute = self.conf('cron_minute')) - addEvent('app.load', self.all) + #addEvent('app.load', self.all) def all(self): diff --git a/couchpotato/core/providers/movie/themoviedb/main.py b/couchpotato/core/providers/movie/themoviedb/main.py index a883797..8bf098e 100644 --- a/couchpotato/core/providers/movie/themoviedb/main.py +++ b/couchpotato/core/providers/movie/themoviedb/main.py @@ -15,12 +15,44 @@ class TheMovieDb(MovieProvider): imageUrl = 'http://hwcdn.themoviedb.org' def __init__(self): + addEvent('provider.movie.by_hash', self.byHash) addEvent('provider.movie.search', self.search) addEvent('provider.movie.info', self.getInfo) # Use base wrapper tmdb.Config.api_key = self.conf('api_key') + def byHash(self, file): + ''' Find movie by hash ''' + + if self.isDisabled(): + return False + + cache_key = 'tmdb.cache.%s' % simplifyString(file) + results = self.getCache(cache_key) + + if not results: + log.debug('Searching for movie by hash: %s' % file) + try: + raw = tmdb.searchByHashingFile(file) + + results = [] + if raw: + try: + results = self.parseMovie(raw) + log.info('Found: %s' % results['titles'][0] + ' (' + str(results['year']) + ')') + + self.setCache(cache_key, results) + return results + except SyntaxError, e: + log.error('Failed to parse XML response: %s' % e) + return False + except: + log.debug('No movies known by hash for: %s' % file) + pass + + return results + def search(self, q, limit = 12): ''' Find movie by name ''' @@ -32,7 +64,7 @@ class TheMovieDb(MovieProvider): results = self.getCache(cache_key) if not results: - log.debug('TheMovieDB - Searching for movie: %s' % q) + log.debug('Searching for movie: %s' % q) raw = tmdb.search(search_string) results = [] @@ -47,7 +79,8 @@ class TheMovieDb(MovieProvider): if nr == limit: break - log.info('TheMovieDB - Found: %s' % [result['titles'][0] + ' (' + str(result['year']) + ')' for result in results]) + log.info('Found: %s' % [result['titles'][0] + ' (' + str(result['year']) + ')' for result in results]) + self.setCache(cache_key, results) return results except SyntaxError, e: diff --git a/couchpotato/core/settings/model.py b/couchpotato/core/settings/model.py index b3a542e..2d10b0f 100644 --- a/couchpotato/core/settings/model.py +++ b/couchpotato/core/settings/model.py @@ -42,13 +42,14 @@ class Library(Entity): tagline = Field(UnicodeText(255)) status = ManyToOne('Status') - movie = OneToMany('Movie') - titles = OneToMany('LibraryTitle', order_by = '-default') + movies = OneToMany('Movie') + titles = OneToMany('LibraryTitle') files = ManyToMany('File') class LibraryTitle(Entity): """""" + using_options(order_by = '-default') title = Field(Unicode) default = Field(Boolean) diff --git a/couchpotato/static/images/checks.png b/couchpotato/static/images/checks.png new file mode 100644 index 0000000..3561d91 Binary files /dev/null and b/couchpotato/static/images/checks.png differ diff --git a/couchpotato/static/images/close_button.png b/couchpotato/static/images/close_button.png deleted file mode 100644 index d11e72b..0000000 Binary files a/couchpotato/static/images/close_button.png and /dev/null differ diff --git a/couchpotato/static/scripts/library/form_replacement/Form.CheckGroup.js b/couchpotato/static/scripts/library/form_replacement/Form.CheckGroup.js new file mode 100644 index 0000000..002fd6e --- /dev/null +++ b/couchpotato/static/scripts/library/form_replacement/Form.CheckGroup.js @@ -0,0 +1,51 @@ +/* +--- +name: Form.CheckGroup +description: Class to represent a group of Form.Check wrapped checkboxes +authors: Bryan J Swift (@bryanjswift) +license: MIT-style license. +requires: [Core/Class.Extras, Core/Element, Core/Element.Event, Form-Replacement/Form.Check] +provides: Form.CheckGroup +... +*/ +if (typeof window.Form === 'undefined') { window.Form = {}; } + +Form.CheckGroup = new Class({ + Implements: [Events,Options], + options: { + checkOptions: {}, + initialValues: {} + }, + checks: [], + initialize: function(group,options) { + if (!Form.Check) { throw 'required Class Form.Check not found'; } + this.setOptions(options); + group = $(group); + if (!group) { return this; } + var checkboxes = group.getElements('input[type=checkbox]'); + checkboxes.each(this.addCheck,this); + }, + addCheck: function(checkbox) { + var initialValues = this.options.initialValues[checkbox.get('name')]; + var checkOptions = {}; + checkOptions.checked = initialValues ? initialValues.contains(checkbox.get('value')) : checkbox.get('checked'); + checkOptions.disabled = checkbox.get('disabled'); + checkbox.store('Form.CheckGroup::data',this); + var check = checkbox.retrieve('Form.Check::data') || new Form.Check(checkbox, Object.append(checkOptions,this.options.checkOptions)); + this.checks.push(check); + }, + checkAll: function() { + this.checks.each(function(check) { if (!check.checked) { check.toggle(); } }); + }, + disable: function() { + this.checks.each(function(check) { check.disable(); }); + this.fireEvent('disable',this); + }, + enable: function() { + this.checks.each(function(check) { check.enable(); }); + this.fireEvent('enable',this); + }, + uncheckAll: function() { + this.checks.each(function(check) { if (check.checked) { check.toggle(); } }); + } +}); diff --git a/couchpotato/static/scripts/library/form_replacement/Form.RadioGroup.js b/couchpotato/static/scripts/library/form_replacement/Form.RadioGroup.js new file mode 100644 index 0000000..f0000b5 --- /dev/null +++ b/couchpotato/static/scripts/library/form_replacement/Form.RadioGroup.js @@ -0,0 +1,59 @@ +/* +--- +name: Form.RadioGroup +description: Class to represent a group of Form.Radio buttons +authors: Bryan J Swift (@bryanjswift) +license: MIT-style license. +requires: [Core/Class.Extras, Core/Element, Core/Element.Event, Form-Replacement/Form.Radio] +provides: Form.RadioGroup +... +*/ +if (typeof window.Form === 'undefined') { window.Form = {}; } + +Form.RadioGroup = new Class({ + Implements: [Events,Options], + options: { + radioOptions: {}, + initialValues: {} + }, + bound: {}, + radios: [], + value: null, + initialize: function(group,options) { + if (!Form.Radio) { throw 'required Class Form.Radio not found'; } + this.setOptions(options); + this.bound = { select: this.select.bind(this) }; + group = $(group); + if (!group) { return this; } + var radios = group.getElements('input[type=radio]'); + radios.each(this.addCheck,this); + }, + addCheck: function(radio,i) { + var initialValues = this.options.initialValues[radio.get('name')]; + var radioOptions = {}; + radioOptions.checked = initialValues ? initialValues.contains(radio.get('value')) : radio.get('checked'); + radioOptions.disabled = radio.get('disabled'); + var check = (radio.retrieve('Form.Radio::data') + || new Form.Radio(radio,Object.append(radioOptions,this.options.radioOptions))); + check.addEvent('onCheck',this.bound.select); + if (check.checked) { i ? this.changed(check) : this.value = check.value; } + radio.store('Form.RadioGroup::data',this); + this.radios.push(check); + }, + changed: function(radio) { + this.value = radio.value; + this.fireEvent('onChange',this); + }, + disable: function() { + this.radios.each(function(radio) { radio.disable(); }); + }, + enable: function() { + this.radios.each(function(radio) { radio.enable(); }); + }, + select: function(checkedRadio) { + this.radios.each(function(radio) { + if (radio.checked && radio.value !== checkedRadio.value) { radio.uncheck(); } + }); + if (checkedRadio.value !== this.value) { this.changed(checkedRadio); } + } +}); diff --git a/couchpotato/static/scripts/library/form_replacement/form_check.js b/couchpotato/static/scripts/library/form_replacement/form_check.js new file mode 100644 index 0000000..3b29819 --- /dev/null +++ b/couchpotato/static/scripts/library/form_replacement/form_check.js @@ -0,0 +1,125 @@ +/* +--- +name: Form.Check +description: Class to represent a checkbox +authors: Bryan J Swift (@bryanjswift) +license: MIT-style license. +requires: [Core/Class.Extras, Core/Element, Core/Element.Event] +provides: Form.Check +... +*/ +if (typeof window.Form === 'undefined') { window.Form = {}; } + +Form.Check = new Class({ + Implements: [Events, Options], + options: { + checked: false, + disabled: false + }, + bound: {}, + checked: false, + config: { + checkedClass: 'checked', + disabledClass: 'disabled', + elementClass: 'check', + highlightedClass: 'highlighted', + storage: 'Form.Check::data' + }, + disabled: false, + element: null, + input: null, + label: null, + value: null, + initialize: function(input, options) { + this.setOptions(options); + this.bound = { + disable: this.disable.bind(this), + enable: this.enable.bind(this), + highlight: this.highlight.bind(this), + removeHighlight: this.removeHighlight.bind(this), + keyToggle: this.keyToggle.bind(this), + toggle: this.toggle.bind(this) + }; + var bound = this.bound; + input = this.input = $(input); + var id = input.get('id'); + this.label = document.getElement('label[for=' + id + ']'); + this.element = new Element('div', { + 'class': input.get('class') + ' ' + this.config.elementClass, + id: id ? id + 'Check' : '', + events: { + click: bound.toggle, + mouseenter: bound.highlight, + mouseleave: bound.removeHighlight + } + }); + this.input.addEvents({ + keypress: bound.keyToggle, + keydown: bound.keyToggle, + keyup: bound.keyToggle + }); + if (this.label) { this.label.addEvent('click', bound.toggle); } + this.element.wraps(input); + this.value = input.get('value'); + if (this.input.checked) { this.check(); } else { this.uncheck(); } + if (this.input.disabled) { this.disable(); } else { this.enable(); } + input.store(this.config.storage, this).addEvents({ + blur: bound.removeHighlight, + focus: bound.highlight + }); + this.fireEvent('create', this); + }, + check: function() { + this.element.addClass(this.config.checkedClass); + this.input.set('checked', 'checked').focus(); + this.checked = true; + this.fireEvent('check', this); + }, + disable: function() { + this.element.addClass(this.config.disabledClass); + this.input.set('disabled', 'disabled'); + this.disabled = true; + this.fireEvent('disable', this); + }, + enable: function() { + this.element.removeClass(this.config.disabledClass); + this.input.erase('disabled'); + this.disabled = false; + this.fireEvent('enable', this); + }, + highlight: function() { + this.element.addClass(this.config.highlightedClass); + this.fireEvent('highlight', this); + }, + removeHighlight: function() { + this.element.removeClass(this.config.highlightedClass); + this.fireEvent('removeHighlight', this); + }, + keyToggle: function(e) { + var evt = new Event(e); + if (evt.key === 'space') { this.toggle(e); } + }, + toggle: function(e) { + var evt; + if (this.disabled) { return this; } + if (e) { + evt = new Event(e).stopPropagation(); + if (evt.target.tagName.toLowerCase() !== 'a') { + evt.stop(); + } + } + if (this.checked) { + this.uncheck(); + } else { + this.check(); + } + this.fireEvent('change', this); + return this; + }, + uncheck: function() { + this.element.removeClass(this.config.checkedClass); + this.input.erase('checked'); + this.checked = false; + this.fireEvent('uncheck', this); + } +}); diff --git a/couchpotato/static/scripts/library/form_replacement/form_dropdown.js b/couchpotato/static/scripts/library/form_replacement/form_dropdown.js new file mode 100644 index 0000000..0b01adf --- /dev/null +++ b/couchpotato/static/scripts/library/form_replacement/form_dropdown.js @@ -0,0 +1,325 @@ +/* +--- +name: Form.Dropdown +description: Class to represent a select input +authors: Bryan J Swift (@bryanjswift) +license: MIT-style license. +requires: [Core/Class.Extras, Core/Element, Core/Element.Event, Form-Replacement/Form.SelectOption] +provides: Form.Dropdown +... +*/ +if (typeof window.Form === 'undefined') { window.Form = {}; } + +Form.Dropdown = new Class({ + Implements: [Events,Options], + options: { + excludedValues: [], + initialValue: null, + mouseLeaveDelay: 350, + selectOptions: {}, + typeDelay: 500 + }, + bound: {}, + dropdownOptions: [], + element: null, + events: {}, + highlighted: null, + input: null, + open: true, + selected: null, + selection: null, + typed: { lastKey: null, value: null, timer: null, pressed: null, shortlist: [], startkey: null }, + value: null, + initialize: function(select,options) { + this.setOptions(options); + select = $(select); + this.bound = { + collapse: this.collapse.bind(this), + expand: this.expand.bind(this), + focus: this.focus.bind(this), + highlightOption: this.highlightOption.bind(this), + keydown: this.keydown.bind(this), + keypress: this.keypress.bind(this), + mouseenterDropdown: this.mouseenterDropdown.bind(this), + mouseleaveDropdown: this.mouseleaveDropdown.bind(this), + mousemove: this.mousemove.bind(this), + removeHighlightOption: this.removeHighlightOption.bind(this), + select: this.select.bind(this), + toggle: this.toggle.bind(this) + }; + this.events = { mouseenter: this.bound.mouseenterDropdown, mouseleave: this.bound.mouseleaveDropdown }; + this.value = this.options.initialValue; + this.initializeCreateElements(select); + var optionElements = select.getElements('option'); + this.updateOptions(optionElements); + this.element.replaces(select); + document.addEvent('click', this.bound.collapse); + var eventName = Browser.ie || Browser.webkit ? 'keydown' : 'keypress'; + var target = Browser.ie ? $(document.body) : window; + target.addEvent('keydown',this.bound.keydown).addEvent(eventName,this.bound.keypress); + }, + initializeCreateElements: function(select) { + var id = select.get('id'); + var dropdown = new Element('div', { + 'class': (select.get('class') + ' select').trim(), + 'id': (id && id !== '') ? id + 'Dropdown' : '' + }); + var menu = new Element('div', {'class': 'menu'}); + var list = new Element('div', {'class': 'list'}); + var options = new Element('ul', {'class': 'options'}); + dropdown.adopt(menu.adopt(list.adopt(options))); + var dropdownSelection = new Element('div', { + 'class': 'selection', + events: {click: this.bound.toggle} + }); + var dropdownBackground = new Element('div', { 'class': 'dropdownBackground' }); + var selection = new Element('span', { 'class': 'selectionDisplay' }); + var input = new Element('input', { + type:'text', + id: id, + name: select.get('name'), + events: { + focus: this.bound.focus + } + }); + dropdownSelection.adopt(dropdownBackground, selection, input); + dropdown.adopt(dropdownSelection); + this.element = dropdown; + this.selection = selection; + this.input = input; + return options; + }, + collapse: function(e) { + this.open = false; + this.element.removeClass('active').removeClass('dropdown-active'); + if (this.selected) { this.selected.removeHighlight(); } + this.element.removeEvents(this.events); + this.fireEvent('collapse', [this, e]); + }, + deselect: function(option) { + option.deselect(); + }, + destroy: function() { + this.element = null; + this.selection = null; + this.input = null; + }, + disable: function() { + this.collapse(); + this.input.set('disabled', 'disabled').removeEvents({blur:this.bound.blur, focus:this.bound.focus}); + this.selection.getParent().removeEvent('click', this.bound.toggle); + this.fireEvent('disable', this); + }, + enable: function() { + this.input.erase('disabled').addEvents({blur:this.bound.blur, focus:this.bound.focus}); + this.selection.getParent().addEvent('click', this.bound.toggle); + this.fireEvent('enable', this); + }, + expand: function(e) { + clearTimeout(this.collapseInterval); + var evt = e ? new Event(e).stop() : null; + this.open = true; + this.input.focus(); + this.element.addClass('active').addClass('dropdown-active'); + if (this.selected) { this.selected.highlight(); } + this.element.addEvents(this.events); + this.fireEvent('expand', [this, e]); + }, + focus: function(e) { + this.expand(); + }, + foundMatch: function(e) { + var typed = this.typed; + var shortlist = typed.shortlist; + var value = typed.value; + var i = 0; + var optionsLength = shortlist.length; + var excludedValues = this.options.excludedValues; + var found = false; + if (!optionsLength) { return; } + var option; + do { + option = shortlist[i]; + if (option.text.toLowerCase().indexOf(value) === 0 && !excludedValues.contains(option.value)) { + found = true; + option.highlight(e); + typed.pressed = i + 1; + i = optionsLength; + } + i = i + 1; + } while(i < optionsLength); + return found; + }, + highlightOption: function(option) { + if (this.highlighted) { this.highlighted.removeHighlight(); } + this.highlighted = option; + }, + isOpen: function() { + return this.open; + }, + keydown: function(e) { + if (!this.open) { return; } + this.dropdownOptions.each(function(option) { option.disable(); }); + document.addEvent('mousemove', this.bound.mousemove); + }, + keypress: function(e) { + if (!this.open) { return; } + (e).stop(); + + var code = e.code, key = e.key; + + var typed = this.typed; + var match, i, options, option, optionsLength, found, first, excludedValues, shortlist; + switch(code) { + case 38: // up + case 37: // left + if (typed.pressed > 0) { typed.pressed = typed.pressed - 1; } + if (!this.highlighted) { this.dropdownOptions.getLast().highlight(e); break; } + match = this.highlighted.element.getPrevious(); + match = match ? match.retrieve('Form.SelectOption::data') : this.dropdownOptions.getLast(); + match.highlight(e); + break; + case 40: // down + case 39: // right + if (typed.shortlist.length > 0) { typed.pressed = typed.pressed + 1; } + if (!this.highlighted) { this.dropdownOptions[0].highlight(e); break; } + match = this.highlighted.element.getNext(); + match = match ? match.retrieve('Form.SelectOption::data') : this.dropdownOptions[0]; + match.highlight(e); + break; + case 13: // enter + e.stop(); + case 9: // tab - skips the stop event but selects the item + this.highlighted.select(); + break; + case 27: // esc + e.stop(); + this.toggle(); + break; + case 32: // space + default: // anything else + if (!(code >= 48 && code <= 122 && (code <= 57 || (code >= 65 && code <= 90) || code >=97) || code === 32)) { + break; + } + if (evt.control || evt.alt || evt.meta) { return; } + // alphanumeric or space + key = code === 32 ? ' ' : key; + clearTimeout(typed.timer); + options = this.dropdownOptions; + optionsLength = options.length; + excludedValues = this.options.excludedValues; + if (typed.timer === null) { // timer is expired + typed.shortlist = []; + if (key === typed.lastKey || key === typed.startkey) { // get next + typed.pressed = typed.pressed + 1; + typed.value = key; + } else { // get first + typed = this.resetTyped(); + typed.value = key; + typed.startkey = key; + typed.pressed = 1; + } + typed.timer = this.resetTyped.delay(500, this); + } else { + if (key === typed.lastKey) { // check for match, if no match get next + typed.value = typed.value + key; + if (this.foundMatch(e)) { // got a match so break + typed.timer = this.resetTyped.delay(500, this); + break; + } else { // no match fall through + typed.shortlist = []; + typed.value = key; + typed.pressed = typed.pressed + 1; + typed.timer = null; + } + } else { // reset timer, get first match, set pressed to found position + typed.timer = this.resetTyped.delay(500, this); + typed.value = typed.value + key; + typed.startkey = typed.value.substring(0, 1); + typed.lastKey = key; + this.foundMatch(e); + break; + } + } + typed.lastKey = key; + shortlist = typed.shortlist; + i = 0; + found = 0; + do { + option = options[i]; + if (option.text.toLowerCase().indexOf(key) === 0 && !excludedValues.contains(option.value)) { + if (found === 0) { first = option; } + found = found + 1; + if (found === typed.pressed) { option.highlight(e); } + shortlist.push(option); + } + i = i + 1; + } while(i < optionsLength); + if (typed.pressed > found) { + first.highlight(e); + typed.pressed = 1; + } + break; + } + }, + mouseenterDropdown: function() { + clearTimeout(this.collapseInterval); + }, + mouseleaveDropdown: function() { + this.collapseInterval = this.options.mouseLeaveDelay ? this.collapse.delay(this.options.mouseLeaveDelay,this) : null; + }, + mousemove: function() { + this.dropdownOptions.each(function(option) { option.enable(); }); + document.removeEvent('mousemove', this.bound.mousemove); + }, + removeHighlightOption: function(option) { + this.highlighted = null; + }, + reset: function() { + if (this.options.initialValue) { + this.dropdownOptions.each(function(o) { + if (o.value === this.options.initialValue) { o.select(); } + }, this); + } else { + this.dropdownOptions[0].select(); + } + }, + resetTyped: function() { + var typed = this.typed; + typed.value = null; + typed.timer = null; + return typed; + }, + select: function(option, e) { + this.dropdownOptions.each(this.deselect); + this.selection.set('html', option.element.get('html')); + var oldValue = this.value; + this.value = option.value; + this.input.set('value', option.value); + this.selected = option; + this.fireEvent('select', [this, e]); + if (oldValue && oldValue !== this.value) { this.fireEvent('change', [this, e]); } + this.collapse(e); + }, + toggle: function(e) { + if (this.open) { this.collapse(e); } + else { this.expand(e); } + }, + updateOptions: function(optionElements) { + var optionsList = this.element.getElement('ul').empty(), + dropdownOptions = this.dropdownOptions.empty(), + selectOptions = this.options.selectOptions; + optionElements.each(function(opt) { + var option = new Form.SelectOption(opt, selectOptions); + option.addEvents({ + 'onHighlight':this.bound.highlightOption, + 'onRemoveHighlight':this.bound.removeHighlightOption, + 'onSelect':this.bound.select + }).owner = this; + if (option.value === this.options.initialValue || opt.get('selected')) { this.select(option); } + dropdownOptions.push(option); + optionsList.adopt(option.element); + }, this); + if (!this.selected && optionElements[0]) { optionElements[0].retrieve('Form.SelectOption::data').select(); } + } +}); diff --git a/couchpotato/static/scripts/library/form_replacement/form_radio.js b/couchpotato/static/scripts/library/form_replacement/form_radio.js new file mode 100644 index 0000000..2fa15f7 --- /dev/null +++ b/couchpotato/static/scripts/library/form_replacement/form_radio.js @@ -0,0 +1,34 @@ +/* +--- +name: Form.Radio +description: Class to represent a radio button +authors: Bryan J Swift (@bryanjswift) +license: MIT-style license. +requires: [Core/Class.Extras, Core/Element, Core/Element.Event, Form-Replacement/Form.Check] +provides: Form.Radio +... +*/ +if (typeof window.Form === 'undefined') { window.Form = {}; } + +Form.Radio = new Class({ + Extends: Form.Check, + config: { + elementClass: 'radio', + storage: 'Form.Radio::data' + }, + initialize: function(input,options) { + this.parent(input,options); + }, + toggle: function(e) { + if (this.element.hasClass('checked') || this.disabled) { return; } + var evt; + if (e) { evt = new Event(e).stop(); } + if (this.checked) { + this.uncheck(); + } else { + this.check(); + } + this.fireEvent(this.checked ? 'onCheck' : 'onUncheck',this); + this.fireEvent('onChange',this); + } +}); \ No newline at end of file diff --git a/couchpotato/static/scripts/library/form_replacement/form_selectoption.js b/couchpotato/static/scripts/library/form_replacement/form_selectoption.js new file mode 100644 index 0000000..2b81140 --- /dev/null +++ b/couchpotato/static/scripts/library/form_replacement/form_selectoption.js @@ -0,0 +1,93 @@ +/* +--- +name: Form.SelectOption +description: Class to represent an option for Form.Dropdown +authors: Bryan J Swift (@bryanjswift) +license: MIT-style license. +requires: [Core/Class.Extras, Core/Element, Core/Element.Event] +provides: Form.SelectOption +... +*/ +if (typeof window.Form === 'undefined') { window.Form = {}; } + +Form.SelectOption = new Class({ + Implements: [Events, Options], + options: { + optionTag: 'li', + selected: false + }, + config: { + highlightedClass: 'highlighted', + optionClass: 'option', + selectedClass: 'selected' + }, + element: null, + bound: {}, + option: null, + selected: false, + text: null, + value: null, + initialize: function(option, options) { + this.setOptions(options); + option = $(option); + this.option = option; + this.bound = { + deselect: this.deselect.bind(this), + highlight: this.highlight.bind(this), + removeHighlight: this.removeHighlight.bind(this), + select: this.select.bind(this) + }; + this.text = option.get('text'); + this.value = option.get('value'); + this.element = new Element(this.options.optionTag, { + 'class': (option.get('class') + ' ' + this.config.optionClass).trim(), + 'html': option.get('html'), + 'events': { + click: this.bound.select, + mouseenter: this.bound.highlight, + mouseleave: this.bound.removeHighlight + } + }); + this.element.store('Form.SelectOption::data', this); + option.store('Form.SelectOption::data', this); + }, + deselect: function(e) { + this.fireEvent('onDeselect', [this, e]); + this.element.removeClass(this.config.selectedClass).addEvent('click', this.bound.select); + this.selected = false; + }, + destroy: function() { + this.element = null; + this.bound = null; + this.option = null; + }, + disable: function() { + this.element.removeEvents({ + mouseenter: this.bound.highlight, + mouseleave: this.bound.removeHighlight + }); + this.fireEvent('onDisable', this); + }, + enable: function() { + this.element.addEvents({ + mouseenter: this.bound.highlight, + mouseleave: this.bound.removeHighlight + }); + this.fireEvent('onEnable', this); + }, + highlight: function(e) { + this.fireEvent('onHighlight', [this, e]); + this.element.addClass(this.config.highlightedClass); + return this; + }, + removeHighlight: function(e) { + this.fireEvent('onRemoveHighlight', [this, e]); + this.element.removeClass(this.config.highlightedClass); + return this; + }, + select: function(e) { + this.fireEvent('onSelect', [this, e]); + this.element.addClass(this.config.selectedClass).removeEvent('click', this.bound.select); + this.selected = true; + } +}); diff --git a/couchpotato/static/scripts/page/manage.js b/couchpotato/static/scripts/page/manage.js index 3525836..d3db0e0 100644 --- a/couchpotato/static/scripts/page/manage.js +++ b/couchpotato/static/scripts/page/manage.js @@ -3,6 +3,24 @@ Page.Manage = new Class({ Extends: PageBase, name: 'manage', - title: 'Do stuff to your existing movies!' + title: 'Do stuff to your existing movies!', + + indexAction: function(param){ + var self = this; + + self.list = new MovieList({ + 'status': 'done', + 'actions': Manage.Action + }); + $(self.list).inject(self.el); + + } + +}); + +var Manage = { + 'Action': { + 'IMBD': IMDBAction + } +} -}) \ No newline at end of file diff --git a/couchpotato/static/scripts/page/settings.js b/couchpotato/static/scripts/page/settings.js index 0066023..c1e9223 100644 --- a/couchpotato/static/scripts/page/settings.js +++ b/couchpotato/static/scripts/page/settings.js @@ -95,7 +95,7 @@ Page.Settings = new Class({ new Element('span', { 'text': 'Show advanced settings' }), - self.advanced_toggle = new Element('input[type=checkbox]', { + self.advanced_toggle = new Element('input[type=checkbox].inlay', { 'events': { 'change': self.showAdvanced.bind(self) } @@ -103,6 +103,10 @@ Page.Settings = new Class({ ) ) ); + + new Form.Check(self.advanced_toggle, { + 'onChange': self.showAdvanced.bind(self) + }) // Create tabs Object.each(self.tabs, function(tab, tab_name){ @@ -132,8 +136,6 @@ Page.Settings = new Class({ }); }); - - self.fireEvent('create'); self.openTab(); @@ -150,7 +152,7 @@ Page.Settings = new Class({ new Element('a', { 'href': '/'+self.name+'/'+tab_name+'/', 'text': label - }) + }).adopt() ).inject(self.tabs_container); if(!self.tabs[tab_name]) @@ -335,7 +337,7 @@ Option.String = new Class({ self.el.adopt( self.createLabel(), - self.input = new Element('input', { + self.input = new Element('input.inlay', { 'type': 'text', 'name': self.postName(), 'value': self.getSettingValue() @@ -365,6 +367,11 @@ Option.Dropdown = new Class({ }) self.input.set('value', self.getSettingValue()); + + var dd = new Form.Dropdown(self.input, { + 'onChange': self.changed.bind(self) + }); + self.input = dd.input; } }); @@ -380,13 +387,17 @@ Option.Checkbox = new Class({ self.el.adopt( self.createLabel().set('for', randomId), - self.input = new Element('input', { + self.input = new Element('input.inlay', { 'name': self.postName(), 'type': 'checkbox', 'checked': self.getSettingValue(), 'id': randomId }) - ) + ); + + new Form.Check(self.input, { + 'onChange': self.changed.bind(self) + }); }, @@ -419,7 +430,7 @@ Option.Enabler = new Class({ var self = this; self.el.adopt( - self.input = new Element('input', { + self.input = new Element('input.inlay', { 'type': 'checkbox', 'checked': self.getSettingValue(), 'id': 'r-'+randomString(), @@ -427,7 +438,16 @@ Option.Enabler = new Class({ 'change': self.checkState.bind(self) } }) - ) + ); + + new Form.Check(self.input, { + 'onChange': self.changed.bind(self) + }); + }, + + changed: function(){ + this.parent(); + this.checkState(); }, checkState: function(){ @@ -457,14 +477,13 @@ Option.Directory = new Class({ type: 'span', browser: '', save_on_change: false, - show_hidden: false, create: function(){ var self = this; self.el.adopt( self.createLabel(), - self.input = new Element('span', { + self.input = new Element('span.directory', { 'text': self.getSettingValue(), 'events': { 'click': self.showBrowser.bind(self) @@ -495,12 +514,19 @@ Option.Directory = new Class({ if(!self.browser) self.browser = new Element('div.directory_list').adopt( - self.back_button = new Element('a.button.back', { - 'text': '', - 'events': { - 'click': self.previousDirectory.bind(self) - } - }), + new Element('div.actions').adopt( + self.back_button = new Element('a.button.back', { + 'text': '', + 'events': { + 'click': self.previousDirectory.bind(self) + } + }), + new Element('label', { + 'text': 'Show hidden files' + }).adopt( + self.show_hidden = new Element('input[type=checkbox].inlay') + ) + ), self.dir_list = new Element('ul', { 'events': { 'click:relay(li)': self.selectDirectory.bind(self) @@ -584,7 +610,7 @@ Option.Directory = new Class({ Api.request('directory.list', { 'data': { 'path': c, - 'show_hidden': +self.show_hidden + 'show_hidden': +self.show_hidden.checked }, 'onComplete': self.fillBrowser.bind(self) }) diff --git a/couchpotato/static/scripts/page/wanted.js b/couchpotato/static/scripts/page/wanted.js index ea177b1..5cb9f39 100644 --- a/couchpotato/static/scripts/page/wanted.js +++ b/couchpotato/static/scripts/page/wanted.js @@ -5,208 +5,26 @@ Page.Wanted = new Class({ name: 'wanted', title: 'Gimmy gimmy gimmy!', - movies: [], - indexAction: function(param){ var self = this; - self.get() - }, - - list: function(){ - var self = this; - - if(!self.movie_container) - self.movie_container = new Element('div.movies').inject(self.el); - - self.movie_container.empty(); - Object.each(self.movies, function(info){ - var m = new Movie(self, {}, info); - $(m).inject(self.movie_container); - m.fireEvent('injected'); + self.list = new MovieList({ + 'status': 'active', + 'actions': Wanted.Action }); + $(self.list).inject(self.el); - self.movie_container.addEvents({ - 'mouseenter:relay(.movie)': function(e, el){ - el.addClass('hover') - }, - 'mouseleave:relay(.movie)': function(e, el){ - el.removeClass('hover') - } - }) - }, - - get: function(status, onComplete){ - var self = this - - if(self.movies.length == 0) - Api.request('movie.list', { - 'data': {}, - 'onComplete': function(json){ - self.store(json.movies); - self.list(); - } - }) - else - self.list() - }, - - store: function(movies){ - var self = this; - - self.movies = movies; } }); -var Movie = new Class({ - - Extends: BlockBase, - - initialize: function(self, options, data){ - var self = this; - - self.data = data; - - self.profile = Quality.getProfile(data.profile_id); - self.parent(self, options); - self.addEvent('injected', self.afterInject.bind(self)) - }, - - create: function(){ - var self = this; - - self.el = new Element('div.movie').adopt( - self.data_container = new Element('div.data', { - 'tween': { - duration: 400, - transition: 'quint:in:out' - } - }).adopt( - self.thumbnail = File.Select.single('poster', self.data.library.files), - self.info_container = new Element('div.info').adopt( - self.title = new Element('div.title', { - 'text': self.getTitle() - }), - self.year = new Element('div.year', { - 'text': self.data.library.year || 'Unknown' - }), - self.rating = new Element('div.rating', { - 'text': self.data.library.rating - }), - self.description = new Element('div.description', { - 'text': self.data.library.plot - }), - self.quality = new Element('div.quality', { - 'text': self.profile.get('label') - }) - ), - self.actions = new Element('div.actions').adopt( - self.action_imdb = new Movie.Action.IMDB(self), - self.action_edit = new Movie.Action.Edit(self), - self.action_refresh = new Movie.Action.Refresh(self), - self.action_delete = new Movie.Action.Delete(self) - ) - ) - ); - - if(!self.data.library.rating) - self.rating.hide(); - - }, - - afterInject: function(){ - var self = this; - - var height = self.getHeight(); - self.el.setStyle('height', height); - }, - - getTitle: function(){ - var self = this; - - var titles = self.data.library.titles; - - var title = titles.filter(function(title){ - return title['default'] - }).pop() - - if(title) - return title.title - else if(titles.length > 0) - return titles[0].title - - return 'Unknown movie' - }, - - slide: function(direction){ - var self = this; - - if(direction == 'in'){ - self.el.addEvent('outerClick', self.slide.bind(self, 'out')) - self.data_container.tween('left', 0, self.getWidth()); - } - else { - self.el.removeEvents('outerClick') - self.data_container.tween('left', self.getWidth(), 0); - } - }, - - getHeight: function(){ - var self = this; - - if(!self.height) - self.height = self.data_container.getCoordinates().height; - - return self.height; - }, - - getWidth: function(){ - var self = this; - - if(!self.width) - self.width = self.data_container.getCoordinates().width; - - return self.width; - }, - - get: function(attr){ - return this.data[attr] || this.data.library[attr] - } - -}); - -var MovieAction = new Class({ - - class_name: 'action', - - initialize: function(movie){ - var self = this; - self.movie = movie; - - self.create(); - self.el.addClass(self.class_name) - }, - - create: function(){}, - - disable: function(){ - this.el.addClass('disable') - }, - - enable: function(){ - this.el.removeClass('disable') - }, - - toElement: function(){ - return this.el +var Wanted = { + 'Action': { + 'IMBD': IMDBAction } +} -}) - -Movie.Action = {} - -Movie.Action.Edit = new Class({ +Wanted.Action.Edit = new Class({ Extends: MovieAction, @@ -229,7 +47,11 @@ Movie.Action.Edit = new Class({ if(!self.options_container){ self.options_container = new Element('div.options').adopt( $(self.movie.thumbnail).clone(), - new Element('div.form').adopt( + new Element('div.form', { + 'styles': { + 'line-height': self.movie.getHeight() + } + }).adopt( self.title_select = new Element('select', { 'name': 'title' }), @@ -244,56 +66,49 @@ Movie.Action.Edit = new Class({ }) ) ).inject(self.movie, 'top'); - } + Array.each(self.movie.data.library.titles, function(alt){ + new Element('option', { + 'text': alt.title + }).inject(self.title_select) + }); + + Object.each(Quality.profiles, function(profile){ + new Element('option', { + 'value': profile.id ? profile.id : profile.data.id, + 'text': profile.label ? profile.label : profile.data.label + }).inject(self.profile_select); + self.profile_select.set('value', self.movie.profile.get('id')); + }); + + } self.movie.slide('in'); }, - save: function(){ + save: function(e){ + (e).stop(); var self = this; Api.request('movie.edit', { 'data': { + 'id': self.movie.get('id'), 'default_title': self.title_select.get('value'), 'profile_id': self.profile_select.get('value') }, 'useSpinner': true, - 'spinnerTarget': self.movie - }) - } - -}) - -Movie.Action.IMDB = new Class({ - - Extends: MovieAction, - id: null, - - create: function(){ - var self = this; - - self.id = self.movie.get('identifier'); - - self.el = new Element('a.imdb', { - 'title': 'Go to the IMDB page of ' + self.movie.getTitle(), - 'events': { - 'click': self.gotoIMDB.bind(self) + 'spinnerTarget': $(self.movie), + 'onComplete': function(){ + self.movie.quality.set('text', self.profile_select.getSelected()[0].get('text')) + self.movie.title.set('text', self.title_select.getSelected()[0].get('text')) } }); - if(!self.id) self.disable(); - }, - - gotoIMDB: function(e){ - var self = this; - (e).stop(); - - window.open('http://www.imdb.com/title/'+self.id+'/'); + self.movie.slide('out'); } }) -Movie.Action.Refresh = new Class({ +Wanted.Action.Refresh = new Class({ Extends: MovieAction, @@ -322,7 +137,7 @@ Movie.Action.Refresh = new Class({ }) -Movie.Action.Delete = new Class({ +Wanted.Action.Delete = new Class({ Extends: MovieAction, @@ -360,7 +175,7 @@ Movie.Action.Delete = new Class({ 'text': 'or' }), new Element('a.button.delete', { - 'text': 'Delete movie', + 'text': 'Delete ' + self.movie.title.get('text'), 'events': { 'click': self.del.bind(self) } @@ -411,4 +226,4 @@ Movie.Action.Delete = new Class({ } -}) +}) \ No newline at end of file diff --git a/couchpotato/static/style/main.css b/couchpotato/static/style/main.css index 0360e44..f5cf9be 100644 --- a/couchpotato/static/style/main.css +++ b/couchpotato/static/style/main.css @@ -1,17 +1,18 @@ /* @override http://localhost:5000/static/style/main.css */ html { - color: #343434; + color: #fff; font-size: 12px; line-height: 1.5; - font-family: Helvetica, Arial, Geneva, sans-serif; + font-family: "Helvetica Neue", Helvetica, Arial, Geneva, sans-serif; height: 100%; + text-shadow: 0 1px 0 #000; } body { margin: 0; padding: 0; - background: #fff; + background: #4e5969; overflow-y: scroll; height: 100%; } @@ -40,11 +41,11 @@ a img { a { text-decoration:none; - color: #6ea1d7; + color: #fff; outline: 0; cursor: pointer; } -a:hover { color: #4d66c4; } +a:hover { color: #f3f3f3; } .page { display: none; @@ -99,7 +100,7 @@ form { } .spinner{ - background: #f7f7f7 url('../images/spinner.gif') no-repeat center; + background: #4e5969 url('../images/spinner.gif') no-repeat center; } .button { @@ -130,9 +131,8 @@ form { /*** Navigation ***/ .header { - background: #f7f7f7; + background: #4e5969; padding:10px; - border-bottom: 1px solid #f1f1f1; height: 60px; -moz-box-shadow: 0 0 30px rgba(0,0,0,0.1); -webkit-box-shadow: 0 0 30px rgba(0,0,0,0.1); @@ -153,7 +153,7 @@ form { padding: 0; } .header .navigation li { - color: #8b8b8b; + color: #fff; display: inline-block; font-size:20px; font-weight: bold; @@ -183,10 +183,151 @@ form { } .header .navigation li a:link, .header .navigation li a:visited { - color: #2c2c2c; + color: #fff; } .header .navigation li a:hover, .header .navigation li a:active { - color: #8b8b8b; + color: #b1d8dc; + } + +/*** Global Styles ***/ +.check { + display: inline-block; + vertical-align: middle; + height: 16px; + width: 16px; + cursor: pointer; +} + .check.highlighted { background-color: #424c59; } + .check.checked { + background-image: url('../images/checks.png'); + background-position: -2px 0; +} +.check input { + display: none !important; +} + +.select { + cursor: pointer; + display: inline-block; +} + + .select .selection { + display: inline-block; + padding: 0 30px 0 20px; + + border-radius:30px; + -moz-border-radius: 30px; + -webkit-border-radius: 30px; + + box-shadow: 0 1px 1px rgba(0,0,0,0.35), inset 0 1px 0px rgba(255,255,255,0.20); + -moz-box-shadow: 0 1px 1px rgba(0,0,0,0.35), inset 0 1px 0px rgba(255,255,255,0.20); + -webkit-box-shadow: 0 1px 1px rgba(0,0,0,0.35), inset 0 1px 0px rgba(255,255,255,0.20); + + background: url('../images/checks.png') no-repeat 94% -53px, -webkit-gradient( + linear, + left bottom, + left top, + color-stop(0, #406db8), + color-stop(1, #5b9bd1) + ); + background: url('../images/checks.png') no-repeat 94% -53px, -moz-linear-gradient( + center top, + #5b9bd1 0%, + #406db8 100% + ); + } + + .select .selection .selectionDisplay { + display: inline-block; + padding-right: 15px; + border-right: 1px solid rgba(0,0,0,0.2); + + box-shadow: 1px 0 0 rgba(255,255,255,0.15); + -moz-box-shadow: 1px 0 0 rgba(255,255,255,0.15); + -webkit-box-shadow: 1px 0 0 rgba(255,255,255,0.15); } + .select .menu { + clear: both; + overflow: hidden; + font-weight: bold; + } + .select .list { + display: none; + background: #282d34; + border: 1px solid #1f242b; + position: absolute; + margin: 28px 0 0 0; + box-shadow: 0 1px 2px rgba(0,0,0,0.4); + -moz-box-shadow: 0 1px 2px rgba(0,0,0,0.4); + -webkit-box-shadow: 0 1px 2px rgba(0,0,0,0.4); + border-radius:3px; + -moz-border-radius: 3px; + -webkit-border-radius: 3px; + + z-index: 3; + } + .select.active .list { + display: block; + } + .select .list ul { + display: block; + width: 100% !important; + } + .select .list li { + padding: 2px 33px 2px 20px; + display: block; + } + .select .list li.highlighted { + background: rgba(255,255,255,0.1); + } + + .select input { display: none; } + +.inlay { + color: #fff; + border: 0; + border-radius:3px; + -moz-border-radius: 3px; + -webkit-border-radius: 3px; + background: #282d34; + box-shadow: inset 0 1px 8px rgba(0,0,0,0.25), 0 1px 0px rgba(255,255,255,0.25); + -moz-box-shadow: inset 0 1px 8px rgba(0,0,0,0.25), 0 1px 0px rgba(255,255,255,0.25); + -webkit-box-shadow: inset 0 1px 8px rgba(0,0,0,0.25), 0 1px 0px rgba(255,255,255,0.25); +} + + .inlay.light { + background: #47515f; + outline: none; + box-shadow: inset 0 1px 8px rgba(0,0,0,0.05), 0 1px 0px rgba(255,255,255,0.15); + -moz-box-shadow: inset 0 1px 8px rgba(0,0,0,0.05), 0 1px 0px rgba(255,255,255,0.15); + -webkit-box-shadow: inset 0 1px 8px rgba(0,0,0,0.05), 0 1px 0px rgba(255,255,255,0.15); + } + + .inlay:focus { + background: #3a4350; + outline: none; + } + +.onlay, .inlay .selected, .inlay > li:hover { + border-radius:3px; + -moz-border-radius: 3px; + -webkit-border-radius: 3px; + border: 1px solid #252930; + box-shadow: inset 0 1px 0px rgba(255,255,255,0.20), 0 0 3px rgba(0,0,0, 0.2); + -moz-box-shadow: inset 0 1px 0px rgba(255,255,255,0.20), 0 0 3px rgba(0,0,0, 0.2); + -webkit-box-shadow: inset 0 1px 0px rgba(255,255,255,0.20), 0 0 3px rgba(0,0,0, 0.2); + background-image: -webkit-gradient( + linear, + left bottom, + left top, + color-stop(0, rgb(55,62,74)), + color-stop(1, rgb(73,83,98)) + ); + background-image: -moz-linear-gradient( + center bottom, + rgb(55,62,74) 0%, + rgb(73,83,98) 100% + ); +} diff --git a/couchpotato/static/style/page/settings.css b/couchpotato/static/style/page/settings.css index 967b77e..7d7ec34 100644 --- a/couchpotato/static/style/page/settings.css +++ b/couchpotato/static/style/page/settings.css @@ -12,35 +12,40 @@ list-style: none; padding: 40px 0; margin: 0; - min-height: 300px; + min-height: 470px; + + background-image: -webkit-gradient( + linear, + right top, + 40% 4%, + color-stop(0, rgba(0,0,0, 0.3)), + color-stop(0.9, rgba(0,0,0, 0)) + ); + background-image: -moz-linear-gradient( + 30% 0% 16deg, + rgba(0,0,0,0) 0%, + rgba(0,0,0,0.3) 100% + ); } .page.settings .tabs a { display: block; - padding: 10px 15px; - border: 1px solid transparent; - position: relative; - z-index: 1; - margin-right: -1px; + padding: 11px 15px; } .page.settings .tabs .active a { - color: black; - background: #fbfbfb; - border-color: #f1f1f1; - border-right-color: transparent; + background: #4e5969; } + .page.settings .containers { width: 75.8%; float: left; padding: 20px 2%; min-height: 300px; - background: #fbfbfb; - border-left: 1px solid #f1f1f1;s } .page.settings .advanced { display: none; - color: #ce3b19; + color: #edc07f; } .page.settings.show_advanced .advanced { display: block; } @@ -57,10 +62,13 @@ font-size: 25px; padding: 0 9px 10px 30px; margin: 0; - border-bottom: 1px solid #f3f3f3; + border-bottom: 1px solid #333; + + box-shadow: 0 1px 0px rgba(255,255,255, 0.15); + -moz-box-shadow: 0 1px 0px rgba(255,255,255, 0.15); + -webkit-box-shadow: 0 1px 0px rgba(255,255,255, 0.15); } .page.settings fieldset h2 .hint { - color: #888; font-size: 12px; margin-left: 10px; } @@ -81,26 +89,44 @@ .page.settings .ctrlHolder { line-height: 25px; padding: 10px 10px 10px 30px; - border-bottom: 1px solid #f3f3f3; font-size: 14px; + border: 0; } .page.settings .ctrlHolder:last-child { border: none; } - .page.settings .ctrlHolder:hover { background: rgba(211,234,254,0.1); } - .page.settings .ctrlHolder.focused:hover { background: rgba(251,246,48,0.29); } + .page.settings .ctrlHolder:hover { background: rgba(255,255,255,0.05); } + .page.settings .ctrlHolder.focused { background: rgba(255,255,255,0.2); } .page.settings .ctrlHolder .formHint { float: right; width: 47%; margin: -18px 0; padding: 0; + color: #fff; } + + .check { + display: inline-block; + vertical-align: middle; + height: 16px; + width: 16px; + cursor: pointer; + } + .check.highlighted { background-color: #424c59; } + .check.checked { + background-image: url('../../images/checks.png'); + background-position: -2px 0; +} + .check input { + display: none; + } - .page.settings .ctrlHolder input[type=checkbox] + .formHint { + .page.settings .check + .formHint { float: none; width: auto; display: inline-block; margin-left: 1% !important; - color: #222; + height: 24px; + vertical-align: middle; } .page.settings .ctrlHolder label { @@ -111,8 +137,7 @@ } .page.settings input[type=text], .page.settings input[type=password] { - border: 1px solid #aaa; - padding: 3px; + padding: 5px 3px; margin: 0; width: 30%; @@ -135,5 +160,44 @@ } .page.settings .advanced_toggle span { padding: 0 5px; } .page.settings.show_advanced .advanced_toggle { - color: #ce3b19; - } \ No newline at end of file + color: #edc07f; + } + + .page.settings .directory { + display: inline-block; + padding: 0 4px; + font-size: 13px; + width: 29.7%; + } + .page.settings .directory_list { + position: absolute; + width: 300px; + margin: 0 0 0 16.5%; + background: #282d34; + border: 1px solid #1f242b; + position: absolute; + box-shadow: 0 1px 2px rgba(0,0,0,0.4); + -moz-box-shadow: 0 1px 2px rgba(0,0,0,0.4); + -webkit-box-shadow: 0 1px 2px rgba(0,0,0,0.4); + border-radius:3px; + -moz-border-radius: 3px; + -webkit-border-radius: 3px; + } + + .page.settings .directory_list ul { + width: 100%; + max-height: 200px; + overflow: auto; + } + + .page.settings .directory_list li { + padding: 2px 10px; + } + + .page.settings .directory_list .actions { + clear: both; + padding: 10px; + background: #414953; + } + .page.settings .directory_list .actions:first-child { border-bottom: 1px solid #1f242b; } + .page.settings .directory_list .actions:last-child { border-top: 1px solid #1f242b; } \ No newline at end of file diff --git a/couchpotato/static/style/page/wanted.css b/couchpotato/static/style/page/wanted.css index 10f8df7..e69de29 100644 --- a/couchpotato/static/style/page/wanted.css +++ b/couchpotato/static/style/page/wanted.css @@ -1,134 +0,0 @@ -/* @override http://localhost:5000/static/style/page/wanted.css */ - -.page.wanted .movies { - padding: 20px 0; -} - - .page.wanted .movie { - overflow: hidden; - position: relative; - background: #999; - - border-radius: 4px; - -moz-border-radius: 4px; - -webkit-border-radius: 4px; - margin: 10px 0; - border: 1px solid #fff; - border-color: #eee #fff #fff #eee; - background: #ddd; - } - .page.wanted .movie:hover { - border-color: #ddd #fff #fff #ddd; - } - .page.wanted .movie:hover .data { - background: #f5f5f5; - } - .page.wanted .data { - padding: 2%; - position: absolute; - width: 96.1%; - top: 0; - left: 0; - background: #f9f9f9; - } - .page.wanted .data:after { - content: ""; - display: block; - height: 0; - clear: both; - visibility: hidden; - } - .page.wanted .data .poster, .page.wanted .options .poster { - overflow: hidden; - float: left; - max-width: 10%; - margin: 0 2% 0 0; - border-radius:3px; - -moz-border-radius: 3px; - -webkit-border-radius: 3px; - -moz-box-shadow: 0 0 10px rgba(0,0,0,0.35); - -webkit-box-shadow: 0 0 10px rgba(0,0,0,0.35); - line-height: 0; - } - - .page.wanted .info { - float: right; - width: 88%; - } - - .page.wanted .info .title { - font-size: 30px; - font-weight: bold; - margin-bottom: 10px; - float: left; - width: 80%; - } - - .page.wanted .info .year { - font-size: 30px; - margin-bottom: 10px; - float: right; - color: #bbb; - width: 10%; - text-align: right; - } - - .page.wanted .info .rating { - font-size: 30px; - margin-bottom: 10px; - color: #444; - float: left; - width: 5%; - padding: 0 0 0 3%; - background: url('../../images/rating.png') no-repeat left center; - } - - .page.wanted .info .description { - clear: both; - width: 95%; - } - .page.wanted .data .actions { - position: absolute; - right: 15px; - bottom: 15px; - line-height: 0; - } - .page.wanted .data:hover .action { opacity: 0.6; } - .page.wanted .data:hover .action:hover { opacity: 1; } - - .page.wanted .data .action { - background: no-repeat center; - display: inline-block; - width: 20px; - height: 20px; - padding: 3px; - opacity: 0; - } - .page.wanted .data .action.refresh { background-image: url('../../images/reload.png'); } - .page.wanted .data .action.delete { background-image: url('../../images/delete.png'); } - .page.wanted .data .action.edit { background-image: url('../../images/edit.png'); } - .page.wanted .data .action.imdb { background-image: url('../../images/imdb.png'); } - - .page.wanted .delete_container { - clear: both; - text-align: center; - font-size: 20px; - position: relative; - } - .page.wanted .delete_container .cancel { - } - .page.wanted .delete_container .or { - padding: 10px; - } - .page.wanted .delete_container .delete { - background-color: #ff321c; - font-weight: normal; - } - .page.wanted .delete_container .delete:hover { - color: #fff; - background-color: #d32917; - } - - .page.wanted .options { - padding: 2%; - } \ No newline at end of file diff --git a/couchpotato/templates/_desktop.html b/couchpotato/templates/_desktop.html index d04fc67..ec64755 100644 --- a/couchpotato/templates/_desktop.html +++ b/couchpotato/templates/_desktop.html @@ -12,6 +12,11 @@ + + + + + @@ -19,6 +24,13 @@ + {% for url in fireEvent('clientscript.get_scripts', as_html = True, single = True) %} + + {% endfor %} + {% for url in fireEvent('clientscript.get_styles', as_html = True, single = True) %} + + {% endfor %} + @@ -26,11 +38,6 @@ - {% for url in fireEvent('clientscript.get_scripts', as_html = True, single = True) %} - {% endfor %} - {% for url in fireEvent('clientscript.get_styles', as_html = True, single = True) %} - {% endfor %} -