├── .yo-rc.json ├── app ├── views │ ├── home.html │ ├── logout.html │ ├── configuration │ │ ├── server.html │ │ ├── accounts.html │ │ └── rules.html │ ├── login.html │ └── connect.html ├── .buildignore ├── robots.txt ├── styles │ ├── configuration │ │ ├── server.scss │ │ ├── base.scss │ │ ├── rules.scss │ │ └── accounts.scss │ ├── components │ │ ├── dropdown.scss │ │ └── message.scss │ ├── modals │ │ └── deleteAccount.scss │ ├── mixins │ │ └── no-select.scss │ ├── directives │ │ ├── configuration │ │ │ ├── header.scss │ │ │ └── option.scss │ │ ├── plex │ │ │ ├── pin.scss │ │ │ ├── login.scss │ │ │ └── home.scss │ │ ├── button-input.scss │ │ └── trakt │ │ │ └── login.scss │ ├── login.scss │ ├── connect.scss │ └── main.scss ├── scripts │ ├── metadata.js │ ├── core │ │ ├── utils.js │ │ └── differ.js │ ├── utils │ │ ├── string.js │ │ └── version.js │ ├── directives │ │ ├── configuration │ │ │ ├── group.js │ │ │ ├── header.js │ │ │ └── option.js │ │ ├── authentication │ │ │ ├── tabs.js │ │ │ ├── plex.js │ │ │ └── trakt.js │ │ ├── button.js │ │ ├── button-input.js │ │ ├── plex │ │ │ ├── home.js │ │ │ ├── pin.js │ │ │ └── login.js │ │ └── trakt │ │ │ └── login.js │ ├── services │ │ ├── raven │ │ │ └── tags.js │ │ └── authentication.js │ ├── controllers │ │ ├── home.js │ │ ├── logout.js │ │ ├── footer.js │ │ ├── login.js │ │ ├── configuration │ │ │ ├── server.js │ │ │ ├── rules.js │ │ │ └── accounts.js │ │ └── connect.js │ ├── filters │ │ └── orderObjectBy.js │ ├── models │ │ ├── plex │ │ │ ├── connection.js │ │ │ ├── connection_manager.js │ │ │ └── server.js │ │ ├── authentication │ │ │ ├── trakt │ │ │ │ ├── pin.js │ │ │ │ ├── basic.js │ │ │ │ └── main.js │ │ │ ├── base.js │ │ │ ├── main.js │ │ │ └── plex.js │ │ ├── configuration │ │ │ └── rules │ │ │ │ ├── user │ │ │ │ ├── rule.js │ │ │ │ └── collection.js │ │ │ │ └── client │ │ │ │ ├── rule.js │ │ │ │ └── collection.js │ │ ├── options.js │ │ └── account.js │ └── app.js ├── modals │ ├── deleteAccount.html │ ├── disconnectAccount.html │ └── authenticationDetails.html ├── directives │ ├── button.html │ ├── plex │ │ ├── pin.html │ │ ├── home.html │ │ └── login.html │ ├── button-input.html │ ├── configuration │ │ ├── option │ │ │ ├── integer.html │ │ │ ├── string.html │ │ │ ├── boolean.html │ │ │ └── enum.html │ │ ├── header.html │ │ └── group.html │ ├── authentication │ │ ├── tabs.html │ │ ├── plex.html │ │ └── trakt.html │ └── trakt │ │ └── login.html ├── 404.html └── index.html ├── .gitattributes ├── .bowerrc ├── .gitignore ├── .travis.yml ├── README.md ├── test ├── .jshintrc ├── spec │ └── controllers │ │ ├── main.js │ │ └── about.js └── karma.conf.js ├── .jshintrc ├── .editorconfig ├── package.json └── bower.json /.yo-rc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /app/views/home.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/views/logout.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto -------------------------------------------------------------------------------- /app/.buildignore: -------------------------------------------------------------------------------- 1 | *.coffee -------------------------------------------------------------------------------- /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "bower_components" 3 | } 4 | -------------------------------------------------------------------------------- /app/robots.txt: -------------------------------------------------------------------------------- 1 | # robotstxt.org 2 | 3 | User-agent: * 4 | Disallow: 5 | -------------------------------------------------------------------------------- /app/styles/configuration/server.scss: -------------------------------------------------------------------------------- 1 | .scroll-container > .options { 2 | margin-bottom: 80px; 3 | } 4 | -------------------------------------------------------------------------------- /app/styles/components/dropdown.scss: -------------------------------------------------------------------------------- 1 | .f-dropdown { 2 | li.active { 3 | background: #DFDFDF; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /app/scripts/metadata.js: -------------------------------------------------------------------------------- 1 | if(typeof window.tfpc == "undefined") { 2 | window.tfpc = {}; 3 | } 4 | window.tfpc.metadata = {}; 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /.sass-cache 3 | /.tmp 4 | /app/scripts/metadata.js 5 | /bower_components 6 | /dist 7 | /node_modules 8 | 9 | *.iml 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - 'iojs' 5 | - '0.12' 6 | - '0.10' 7 | before_script: 8 | - 'npm install -g bower grunt-cli' 9 | - 'bower install' 10 | -------------------------------------------------------------------------------- /app/styles/modals/deleteAccount.scss: -------------------------------------------------------------------------------- 1 | .reveal-modal div.delete-account { 2 | .controls { 3 | padding-top: 20px; 4 | } 5 | 6 | a.button { 7 | margin-bottom: 0; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /app/styles/mixins/no-select.scss: -------------------------------------------------------------------------------- 1 | @mixin no-select { 2 | -webkit-touch-callout: none; 3 | -webkit-user-select: none; 4 | -khtml-user-select: none; 5 | -moz-user-select: none; 6 | -ms-user-select: none; 7 | user-select: none; 8 | } 9 | -------------------------------------------------------------------------------- /app/scripts/core/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .factory('Utils', function () { 5 | return { 6 | isDefined: function(value) { 7 | return !!(typeof value !== 'undefined' && value !== null); 8 | } 9 | }; 10 | }); 11 | -------------------------------------------------------------------------------- /app/scripts/utils/string.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .factory('StringUtil', function() { 5 | return { 6 | endsWith: function(str, suffix) { 7 | return str.indexOf(suffix, str.length - suffix.length) !== -1; 8 | } 9 | }; 10 | }); 11 | -------------------------------------------------------------------------------- /app/styles/directives/configuration/header.scss: -------------------------------------------------------------------------------- 1 | co-configuration-header { 2 | .title { 3 | padding: 0 10px; 4 | } 5 | 6 | .actions { 7 | padding: 5px 0 0; 8 | 9 | text-align: right; 10 | 11 | button { 12 | font-size: 17px; 13 | padding: 0.375rem 1.25rem; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/modals/deleteAccount.html: -------------------------------------------------------------------------------- 1 |
2 |
Are you sure you want to delete {{ account.name }}?
3 | 4 |
5 | Cancel 6 | Delete 7 |
8 |
9 | -------------------------------------------------------------------------------- /app/scripts/directives/configuration/group.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .directive('coConfigurationGroup', function() { 5 | return { 6 | restrict: 'E', 7 | scope: { 8 | groups: '=coGroups' 9 | }, 10 | templateUrl: 'directives/configuration/group.html' 11 | }; 12 | }); 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Trakt.tv (for Plex) - Configuration 2 | 3 | [![](https://img.shields.io/badge/license-GPLv3-blue.svg?style=flat-square)][license] 4 | 5 | Configuration site for the *Trakt.tv (for Plex)* plugin, latest build is available at http://trakt-for-plex.github.io/configuration. 6 | 7 | [license]: https://github.com/trakt-for-plex/configuration/blob/master/LICENSE.md 8 | -------------------------------------------------------------------------------- /test/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "bitwise": true, 3 | "browser": true, 4 | "curly": true, 5 | "eqeqeq": true, 6 | "esnext": true, 7 | "jasmine": true, 8 | "latedef": true, 9 | "noarg": true, 10 | "node": true, 11 | "strict": true, 12 | "undef": true, 13 | "unused": true, 14 | "globals": { 15 | "angular": false, 16 | "inject": false 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/modals/disconnectAccount.html: -------------------------------------------------------------------------------- 1 |
2 |
Are you sure you want to disconnect {{ username }} from {{ account.name }}?
3 | 4 |
5 | Cancel 6 | Disconnect 7 |
8 |
9 | -------------------------------------------------------------------------------- /app/directives/button.html: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /app/scripts/services/raven/tags.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .factory('RavenTags', function() { 5 | var tags = {}; 6 | 7 | return { 8 | update: function(update) { 9 | _.each(update, function(value, key) { 10 | tags[key] = value; 11 | }); 12 | 13 | Raven.setTagsContext(tags); 14 | } 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /app/views/configuration/server.html: -------------------------------------------------------------------------------- 1 |
2 | 8 | 9 | 10 | 11 |
12 | -------------------------------------------------------------------------------- /app/scripts/controllers/home.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * @ngdoc function 5 | * @name configurationApp.controller:HomeController 6 | * @description 7 | * # HomeController 8 | * Controller of the configurationApp 9 | */ 10 | angular.module('configurationApp') 11 | .controller('HomeController', function($location) { 12 | $location.path('/configuration/server'); 13 | $location.search(''); 14 | }); 15 | -------------------------------------------------------------------------------- /app/scripts/controllers/logout.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * @ngdoc function 5 | * @name configurationApp.controller:LogoutController 6 | * @description 7 | * # LogoutController 8 | * Controller of the configurationApp 9 | */ 10 | angular.module('configurationApp') 11 | .controller('LogoutController', function(Authentication, $location) { 12 | Authentication.logout(); 13 | 14 | $location.path('/login'); 15 | $location.search(''); 16 | }); 17 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "bitwise": true, 3 | "browser": true, 4 | "curly": true, 5 | "eqeqeq": true, 6 | "esnext": true, 7 | "latedef": true, 8 | "noarg": true, 9 | "node": true, 10 | "strict": true, 11 | "undef": true, 12 | "unused": true, 13 | "globals": { 14 | "angular": false, 15 | "Ladda": false, 16 | "md5": false, 17 | "plex": false, 18 | "semver": false, 19 | "trakt": false, 20 | "$": false, 21 | "_": false 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/directives/plex/pin.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
6 | {{ current.code }} 7 |
8 |
9 | Error
10 | Unable to retrieve pin details from plex.tv 11 |
12 |
13 | -------------------------------------------------------------------------------- /app/views/login.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
Login to plex.tv
4 | 5 |
6 | 7 | 8 | 9 |
10 |
11 | 12 | 15 | 16 |
17 | -------------------------------------------------------------------------------- /app/styles/components/message.scss: -------------------------------------------------------------------------------- 1 | small.message { 2 | display: block; 3 | 4 | margin: 0 -1px 10px !important; 5 | 6 | padding: 0.375rem 0.5625rem 0.375rem 0.5625rem; 7 | 8 | font-size: 0.75rem; 9 | font-style: italic; 10 | font-weight: normal; 11 | 12 | color: #FFFFFF; 13 | } 14 | 15 | small.message.info { 16 | background: #13736F; 17 | } 18 | 19 | small.message.warning { 20 | background: #BDB820; 21 | } 22 | 23 | small.message.error { 24 | background: #BD2820; 25 | } 26 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | 10 | # Change these settings to your own preference 11 | indent_style = space 12 | indent_size = 2 13 | 14 | # We recommend you to keep these unchanged 15 | end_of_line = lf 16 | charset = utf-8 17 | trim_trailing_whitespace = true 18 | insert_final_newline = true 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | -------------------------------------------------------------------------------- /app/scripts/directives/configuration/header.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .directive('coConfigurationHeader', function() { 5 | return { 6 | restrict: 'E', 7 | scope: { 8 | title: '=coTitle', 9 | 10 | self: '=coSelf', 11 | refresh: '=coRefresh', 12 | discard: '=coDiscard', 13 | save: '=coSave', 14 | delete: '=coDelete' 15 | }, 16 | templateUrl: 'directives/configuration/header.html' 17 | }; 18 | }); 19 | -------------------------------------------------------------------------------- /app/scripts/directives/authentication/tabs.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .directive('coAuthenticationTabs', function(Utils) { 5 | return { 6 | restrict: 'E', 7 | scope: { 8 | account: '=coAccount' 9 | }, 10 | templateUrl: 'directives/authentication/tabs.html', 11 | 12 | controller: function($scope) { 13 | $scope.selected = 'trakt'; 14 | 15 | $scope.select = function(key) { 16 | $scope.selected = key; 17 | } 18 | } 19 | }; 20 | }); 21 | -------------------------------------------------------------------------------- /app/scripts/filters/orderObjectBy.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .filter('orderObjectBy', function() { 5 | return function(items, field, reverse) { 6 | var filtered = []; 7 | 8 | angular.forEach(items, function(item) { 9 | filtered.push(item); 10 | }); 11 | 12 | filtered.sort(function (a, b) { 13 | return (a[field] > b[field] ? 1 : -1); 14 | }); 15 | 16 | if(reverse) { 17 | filtered.reverse(); 18 | } 19 | 20 | return filtered; 21 | }; 22 | }); 23 | -------------------------------------------------------------------------------- /app/styles/directives/plex/pin.scss: -------------------------------------------------------------------------------- 1 | co-plex-pin { 2 | .code { 3 | position: relative; 4 | 5 | height: 112px; 6 | 7 | background-color: rgba(0,0,0,0.3); 8 | color: white; 9 | 10 | .code-spinner { 11 | position: absolute; 12 | top: 0; 13 | left: 0; 14 | right: 0; 15 | bottom: 0; 16 | } 17 | 18 | .code-value { 19 | font-size: 74px; 20 | letter-spacing: 10px; 21 | line-height: 112px; 22 | text-align: center; 23 | } 24 | 25 | .code-error { 26 | padding: 10px; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/spec/controllers/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('Controller: MainCtrl', function () { 4 | 5 | // load the controller's module 6 | beforeEach(module('configurationApp')); 7 | 8 | var MainCtrl; 9 | 10 | // Initialize the controller and a mock scope 11 | beforeEach(inject(function ($controller, $rootScope) { 12 | MainCtrl = $controller('MainCtrl', { 13 | // place here mocked dependencies 14 | }); 15 | })); 16 | 17 | it('should attach a list of awesomeThings to the scope', function () { 18 | expect(MainCtrl.awesomeThings.length).toBe(3); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /app/scripts/core/differ.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .factory('Differ', function () { 5 | function run(a, b) { 6 | var r = {}; 7 | 8 | _.each(a, function(v, k) { 9 | if(b[k] === v) { 10 | return; 11 | } 12 | 13 | if(_.isObject(v)) { 14 | v = run(v, b[k]); 15 | 16 | if(Object.keys(v).length === 0) { 17 | return; 18 | } 19 | } 20 | 21 | r[k] = v; 22 | }); 23 | 24 | return r; 25 | } 26 | 27 | return { 28 | run: run 29 | }; 30 | }); 31 | -------------------------------------------------------------------------------- /test/spec/controllers/about.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('Controller: AboutCtrl', function () { 4 | 5 | // load the controller's module 6 | beforeEach(module('configurationApp')); 7 | 8 | var AboutCtrl; 9 | 10 | // Initialize the controller and a mock scope 11 | beforeEach(inject(function ($controller, $rootScope) { 12 | AboutCtrl = $controller('AboutCtrl', { 13 | // place here mocked dependencies 14 | }); 15 | })); 16 | 17 | it('should attach a list of awesomeThings to the scope', function () { 18 | expect(AboutCtrl.awesomeThings.length).toBe(3); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /app/scripts/controllers/footer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * @ngdoc function 5 | * @name configurationApp.controller:FooterController 6 | * @description 7 | * # FooterController 8 | * Controller of the configurationApp 9 | */ 10 | angular.module('configurationApp') 11 | .controller('FooterController', function($scope) { 12 | $scope.connectionVisible = false; 13 | $scope.versionVisible = false; 14 | 15 | $scope.toggleConnection = function() { 16 | $scope.connectionVisible = !$scope.connectionVisible; 17 | }; 18 | 19 | $scope.toggleVersion = function() { 20 | $scope.versionVisible = !$scope.versionVisible; 21 | }; 22 | }); 23 | -------------------------------------------------------------------------------- /app/views/connect.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
Connect
4 |
5 | 6 |
7 |
8 |
{{ server.name }}
9 |
{{ server.status }}
10 |
11 |
12 | 13 |
14 | 15 |
16 |
17 | -------------------------------------------------------------------------------- /app/directives/button-input.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 | 6 |
7 | 16 |
17 |
18 | 19 | {{ error }} 20 | -------------------------------------------------------------------------------- /app/scripts/models/plex/connection.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .factory('PlexConnection', function($http) { 5 | function PlexConnection() { 6 | this.client = null; 7 | this.uri = null; 8 | } 9 | 10 | PlexConnection.prototype.request = function(path, config) { 11 | // build url 12 | config.url = this.uri + '/' + path; 13 | 14 | // create request 15 | return $http(config); 16 | }; 17 | 18 | PlexConnection.fromElement = function(e) { 19 | var c = new PlexConnection(); 20 | 21 | // Set attributes 22 | c.uri = e._uri; 23 | 24 | return c; 25 | }; 26 | 27 | return PlexConnection; 28 | }); 29 | -------------------------------------------------------------------------------- /app/styles/directives/button-input.scss: -------------------------------------------------------------------------------- 1 | co-button-input { 2 | .cobi { 3 | display: table; 4 | width: 100%; 5 | } 6 | 7 | .cobi-input { 8 | display: none; 9 | width: 100%; 10 | 11 | vertical-align: middle; 12 | 13 | input { 14 | height: 32px; 15 | 16 | margin: 0; 17 | } 18 | } 19 | 20 | .cobi-button { 21 | display: table-cell; 22 | 23 | vertical-align: middle; 24 | 25 | button { 26 | margin: 0 -1px 0 0; 27 | } 28 | } 29 | 30 | .cobi.opened { 31 | .cobi-input { 32 | display: table-cell; 33 | } 34 | } 35 | 36 | .error { 37 | width: 100%; 38 | 39 | margin-top: 0; 40 | 41 | font-size: 0.7rem; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/styles/login.scss: -------------------------------------------------------------------------------- 1 | .login.panel { 2 | width: 320px; 3 | 4 | margin-left: auto; 5 | margin-right: auto; 6 | margin-top: 10%; 7 | 8 | padding: 0; 9 | 10 | .header { 11 | position: relative; 12 | 13 | padding: 10px 14px; 14 | 15 | background: rgba(0, 0, 0, 0.08); 16 | 17 | h5 { 18 | margin: 0; 19 | } 20 | 21 | .help { 22 | position: absolute; 23 | top: 0; 24 | right: 14px; 25 | bottom: 0; 26 | 27 | a.details { 28 | display: block; 29 | margin-top: 2px; 30 | 31 | font-size: 22px; 32 | 33 | color: #AAA; 34 | } 35 | } 36 | } 37 | 38 | .basic-authentication, .pin-authentication { 39 | padding: 14px; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/directives/configuration/option/integer.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 |
7 | 8 | 9 |
10 | 11 |
12 | × 13 |
14 |
15 | 16 |
17 |
18 | -------------------------------------------------------------------------------- /app/directives/configuration/option/string.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 |
7 | 8 | 9 |
10 | 11 |
12 | × 13 |
14 |
15 | 16 |
17 |
18 | -------------------------------------------------------------------------------- /app/directives/configuration/header.html: -------------------------------------------------------------------------------- 1 |
2 |
{{ title }}
3 |
4 | 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | -------------------------------------------------------------------------------- /app/directives/configuration/option/boolean.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 |
6 | 7 | 8 |
9 |
10 | 11 |
12 | × 13 |
14 |
15 | 16 |
17 |
18 | -------------------------------------------------------------------------------- /app/scripts/utils/version.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .factory('VersionUtil', function() { 5 | return { 6 | compare: function(left, right) { 7 | if (typeof left + typeof right !== 'stringstring') { 8 | return false; 9 | } 10 | 11 | var a = left.split('.'), 12 | b = right.split('.'), 13 | i = 0, 14 | len = Math.max(a.length, b.length); 15 | 16 | for (; i < len; i++) { 17 | if ((a[i] && !b[i] && parseInt(a[i]) > 0) || (parseInt(a[i]) > parseInt(b[i]))) { 18 | return 1; 19 | } else if ((b[i] && !a[i] && parseInt(b[i]) > 0) || (parseInt(a[i]) < parseInt(b[i]))) { 20 | return -1; 21 | } 22 | } 23 | 24 | return 0; 25 | } 26 | }; 27 | }); 28 | -------------------------------------------------------------------------------- /app/directives/configuration/option/enum.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 |
7 | 8 | 9 |
10 | 11 |
12 | × 13 |
14 |
15 | 16 |
17 |
18 | -------------------------------------------------------------------------------- /app/styles/directives/trakt/login.scss: -------------------------------------------------------------------------------- 1 | @import '../../mixins/no-select'; 2 | 3 | co-trakt-login { 4 | .fields { 5 | margin-bottom: 14px; 6 | } 7 | 8 | label > input[type="text"], input[type="password"] { 9 | margin: 1px 0 1rem 0; 10 | } 11 | 12 | label:last-child > input[type="text"], input[type="password"] { 13 | margin-bottom: 0; 14 | } 15 | 16 | a.button, input[type="submit"] { 17 | margin: 0; 18 | } 19 | 20 | .buttons { 21 | text-align: right; 22 | } 23 | 24 | .buttons.small { 25 | height: 44px; 26 | } 27 | 28 | .buttons.tiny { 29 | height: 33px; 30 | } 31 | 32 | .buttons-left { 33 | float: left; 34 | 35 | .button { 36 | margin-right: 6px; 37 | } 38 | } 39 | 40 | .buttons, 41 | .buttons .button, 42 | .buttons-left { 43 | @include no-select(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/modals/authenticationDetails.html: -------------------------------------------------------------------------------- 1 |

Login to plex.tv

2 | 3 | This web-application requires access to your plex.tv account to: 4 |
    5 |
  • Discover servers linked to your account
  • 6 |
  • Connect to your server
  • 7 |
  • Authenticate with the trakt plugin
  • 8 |
9 | 10 |

11 | Your plex.tv credentials are sent directly to the official plex.tv API to exchange for an authentication token, this token is stored locally in your browser until you logout or clear your browser “Cookies and other site and plugin data”. 12 |

13 | 14 |

15 | Access to your plex.tv account can be revoked at any time at http://app.plex.tv/web/app#!/settings/devices, look for a device named “trakt (for Plex) - Configuration”. 16 |

17 | 18 | × 19 | -------------------------------------------------------------------------------- /app/scripts/controllers/login.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * @ngdoc function 5 | * @name configurationApp.controller:LoginController 6 | * @description 7 | * # LoginController 8 | * Controller of the configurationApp 9 | */ 10 | angular.module('configurationApp') 11 | .controller('LoginController', function(Authentication, $location, $modal, $q, $scope) { 12 | $scope.onAuthenticated = function(token, user){ 13 | if(Authentication.login(token, user)) { 14 | // Login successful 15 | $scope.$r.redirect(); 16 | } 17 | }; 18 | 19 | $scope.showAuthenticationDetails = function() { 20 | var modal = $modal.open({ 21 | templateUrl: 'modals/authenticationDetails.html', 22 | windowClass: 'small' 23 | }); 24 | 25 | modal.result.then(function() { 26 | console.log('Modal closed'); 27 | }, function () { 28 | console.log('Modal dismissed'); 29 | }); 30 | }; 31 | }); 32 | -------------------------------------------------------------------------------- /app/directives/authentication/tabs.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | Trakt 5 |
6 |
7 | Plex 8 |
9 |
10 | 11 |
12 |
13 | 16 | 17 |
18 | 19 |
20 | 23 | 24 |
25 |
26 |
27 | -------------------------------------------------------------------------------- /app/scripts/controllers/configuration/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * @ngdoc function 5 | * @name configurationApp.controller:ServerController 6 | * @description 7 | * # ServerController 8 | * Controller of the configurationApp 9 | */ 10 | angular.module('configurationApp') 11 | .controller('ServerController', function (Options, $q, $rootScope, $scope) { 12 | $scope.options = null; 13 | 14 | $scope.refresh = function() { 15 | // Retrieve server options 16 | return $rootScope.$s.call('option.list', []).then(function(options) { 17 | // Parse options 18 | $scope.options = new Options(options); 19 | }, function() { 20 | return $q.reject(); 21 | }); 22 | }; 23 | 24 | $scope.discard = function() { 25 | return $scope.options.discard(); 26 | }; 27 | 28 | $scope.save = function() { 29 | return $scope.options.save($rootScope.$s); 30 | }; 31 | 32 | // Initial preferences refresh 33 | $scope.refresh(); 34 | }); 35 | -------------------------------------------------------------------------------- /app/styles/configuration/base.scss: -------------------------------------------------------------------------------- 1 | .options { 2 | margin-top: 25px; 3 | 4 | .group > .header, 5 | .sub-group > div > .header { 6 | margin: 10px 0; 7 | padding: 5px 10px; 8 | 9 | background-color: rgba(0,0,0,0.05); 10 | 11 | .columns { 12 | padding: 0; 13 | } 14 | 15 | .switch { 16 | margin: 0; 17 | 18 | text-align: right; 19 | 20 | label { 21 | margin-top: 7px; 22 | } 23 | } 24 | } 25 | 26 | h5, h6 { 27 | line-height: 2.6; 28 | 29 | margin: 0; 30 | padding-left: 5px; 31 | } 32 | 33 | h6 { 34 | line-height: 1.6; 35 | 36 | border-bottom: 1px solid #AAA; 37 | } 38 | 39 | .group-options { 40 | label + select, label + input { 41 | margin-top: 3px; 42 | } 43 | } 44 | 45 | .group > .columns, .sub-group > .columns { 46 | padding: 0; 47 | } 48 | 49 | .sub-group { 50 | .group-options { 51 | margin: 0; 52 | } 53 | } 54 | 55 | .sub-group > div > .header { 56 | background-color: transparent; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/styles/connect.scss: -------------------------------------------------------------------------------- 1 | .panel.connect { 2 | height: 300px; 3 | 4 | margin-top: 10%; 5 | padding: 0; 6 | 7 | .header { 8 | position: relative; 9 | 10 | padding: 10px 14px; 11 | 12 | background: rgba(0, 0, 0, 0.08); 13 | 14 | h5 { 15 | margin: 0; 16 | } 17 | } 18 | 19 | .servers { 20 | position: absolute; 21 | top: 38px; 22 | left: 0; 23 | right: 0; 24 | bottom: 0; 25 | 26 | overflow-y: scroll; 27 | 28 | .server { 29 | padding: 10px 15px; 30 | 31 | .error { 32 | font-size: 10px; 33 | } 34 | } 35 | 36 | .server:hover { 37 | background-color: rgba(0, 0, 0, 0.05); 38 | cursor: pointer; 39 | } 40 | } 41 | 42 | .status { 43 | position: absolute; 44 | top: 0; 45 | left: 0; 46 | right: 0; 47 | bottom: 0; 48 | 49 | height: 298px; 50 | 51 | background-color: #f2f2f2; 52 | 53 | .busy { 54 | position: relative; 55 | top: 50%; 56 | 57 | transform: translateY(-50%); 58 | 59 | .spinner { 60 | margin: 0 auto; 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /app/styles/directives/plex/login.scss: -------------------------------------------------------------------------------- 1 | @import '../../mixins/no-select'; 2 | 3 | co-plex-login { 4 | .error { 5 | margin: 0 -1px; 6 | } 7 | 8 | .fields { 9 | margin-bottom: 14px; 10 | } 11 | 12 | label > input[type="text"], input[type="password"] { 13 | margin: 1px 0 1rem 0; 14 | } 15 | 16 | label:last-child > input[type="text"], input[type="password"] { 17 | margin-bottom: 0; 18 | } 19 | 20 | a.button, input[type="submit"] { 21 | margin: 0; 22 | } 23 | 24 | .buttons { 25 | text-align: right; 26 | } 27 | 28 | .buttons.small { 29 | height: 44px; 30 | } 31 | 32 | .buttons.tiny { 33 | height: 33px; 34 | } 35 | 36 | .buttons-left { 37 | float: left; 38 | 39 | .button { 40 | margin-right: 6px; 41 | } 42 | } 43 | 44 | .buttons, 45 | .buttons .button, 46 | .buttons-left { 47 | @include no-select(); 48 | } 49 | 50 | .pin-authentication { 51 | .instructions { 52 | margin-bottom: 1px; 53 | 54 | font-size: 14px; 55 | line-height: 21px; 56 | } 57 | 58 | co-plex-pin .code { 59 | margin-bottom: 14px; 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /app/directives/authentication/plex.html: -------------------------------------------------------------------------------- 1 |
2 | {{ message.content }} 3 |
4 | 5 |
6 |
7 | 16 | 17 |
18 | 19 |
20 | 21 | 22 |
23 |

{{ plex.title }}

24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 |
34 |
35 |
36 |
37 | -------------------------------------------------------------------------------- /app/directives/configuration/group.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
{{ group.name }}
5 |
6 | 7 |
8 | 9 | 10 |
11 |
12 | 13 |
14 | 15 |
16 | 17 |
18 |
19 |
20 |
21 |
{{ group.name }}
22 |
23 |
24 | 25 |
26 | 27 |
28 |
29 |
30 |
31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "configuration", 3 | "private": true, 4 | "devDependencies": { 5 | "grunt": "^0.4.5", 6 | "grunt-angular-templates": "^0.5.7", 7 | "grunt-autoprefixer": "^2.0.0", 8 | "grunt-build-control": "^0.5.0", 9 | "grunt-concurrent": "^1.0.0", 10 | "grunt-contrib-clean": "^0.6.0", 11 | "grunt-contrib-compass": "^1.0.0", 12 | "grunt-contrib-concat": "^0.5.0", 13 | "grunt-contrib-connect": "^0.9.0", 14 | "grunt-contrib-copy": "^0.7.0", 15 | "grunt-contrib-cssmin": "^0.12.0", 16 | "grunt-contrib-htmlmin": "^0.4.0", 17 | "grunt-contrib-imagemin": "^0.9.4", 18 | "grunt-contrib-jshint": "^0.11.0", 19 | "grunt-contrib-uglify": "^0.7.0", 20 | "grunt-contrib-watch": "^0.6.1", 21 | "grunt-filerev": "^2.1.2", 22 | "grunt-git-describe": "^2.3.2", 23 | "grunt-karma": "*", 24 | "grunt-newer": "^1.1.0", 25 | "grunt-ng-annotate": "^0.9.2", 26 | "grunt-svgmin": "^2.0.0", 27 | "grunt-usemin": "^3.0.0", 28 | "grunt-wiredep": "^2.0.0", 29 | "jit-grunt": "^0.9.1", 30 | "jshint-stylish": "^1.0.0", 31 | "karma-jasmine": "*", 32 | "karma-phantomjs-launcher": "*", 33 | "time-grunt": "^1.0.0" 34 | }, 35 | "engines": { 36 | "node": ">=0.10.0" 37 | }, 38 | "scripts": { 39 | "test": "grunt test" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "configuration", 3 | "version": "1.4.2", 4 | "dependencies": { 5 | "angular": "^1.4.1", 6 | "angular-animate": "^1.4.1", 7 | "angular-aria": "^1.4.1", 8 | "angular-cookies": "^1.4.1", 9 | "angular-messages": "^1.4.1", 10 | "angular-resource": "^1.4.1", 11 | "angular-route": "^1.4.1", 12 | "angular-sanitize": "^1.4.1", 13 | "angular-spinner": "~0.6.2", 14 | "angular-touch": "^1.4.1", 15 | "foundation": "~5.5.2", 16 | "angular-foundation": "~0.6.0", 17 | "ua-parser-js": "~0.7.7", 18 | "angular-xml": "~2.1.0", 19 | "underscore": "~1.8.3", 20 | "foundation-icon-fonts": "*", 21 | "cerealizer.js": "git@github.com:fuzeman/Cerealizer.js.git", 22 | "ladda-foundation-5": "git@github.com:fuzeman/ladda-foundation-5.git", 23 | "trakt.js": "git@github.com:fuzeman/trakt.js.git", 24 | "plex.js": "git@github.com:fuzeman/plex.js.git", 25 | "selectize": "~0.12.1", 26 | "fontawesome": "~4.3.0", 27 | "angular-selectize2": "~1.2.1", 28 | "jquery-sortable": "~0.9.13", 29 | "raven-js": "^3.9.1", 30 | "angular-raven": "^0.6.2", 31 | "JavaScript-MD5": "~1.1.0", 32 | "angulartics": "~0.19.0", 33 | "angular-marked": "^1.2.0" 34 | }, 35 | "devDependencies": { 36 | "angular-mocks": "^1.4.1" 37 | }, 38 | "appPath": "app", 39 | "moduleName": "configurationApp" 40 | } 41 | -------------------------------------------------------------------------------- /app/scripts/models/authentication/trakt/pin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .factory('TraktPinAuthentication', function(BaseAuthentication, Utils, $q) { 5 | function TraktPinAuthentication(main) { 6 | this.main = main; 7 | 8 | this.changed = false; 9 | this.original = null; 10 | 11 | // Authorization details 12 | this.authorization = null; 13 | this.code = null; 14 | 15 | // State 16 | this.messages = []; 17 | this.state = ''; 18 | } 19 | 20 | TraktPinAuthentication.prototype.current = function() { 21 | var authorization = this.authorization; 22 | 23 | if(!Utils.isDefined(authorization)) { 24 | return {}; 25 | } 26 | 27 | return { 28 | authorization: { 29 | oauth: $.extend({ 30 | code: this.code 31 | }, authorization) 32 | } 33 | }; 34 | }; 35 | 36 | TraktPinAuthentication.prototype.update = function(data) { 37 | this.changed = false; 38 | this.original = angular.copy(data); 39 | 40 | // Authorization details 41 | this.authorization = null; 42 | this.code = data.code; 43 | 44 | // State 45 | this.messages = []; 46 | this.state = data.state; 47 | }; 48 | 49 | TraktPinAuthentication.prototype.updateAuthorization = function(authorization) { 50 | this.authorization = authorization; 51 | this.changed = true; 52 | }; 53 | 54 | return TraktPinAuthentication; 55 | }); 56 | -------------------------------------------------------------------------------- /app/directives/authentication/trakt.html: -------------------------------------------------------------------------------- 1 |
2 | {{ message.content }} 3 | {{ message.content }} 4 | {{ message.content }} 5 |
6 | 7 |
8 |
9 | 21 | 22 |
23 | 24 |
25 | 26 | 27 |
28 |

{{ trakt.username }}

29 | 30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
39 |
40 |
41 |
42 | 43 | -------------------------------------------------------------------------------- /app/scripts/models/authentication/base.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .factory('BaseAuthentication', function() { 5 | var stateIds = { 6 | 'valid': 0, 7 | 'empty': 1, 8 | 'warning': 2, 9 | 'error': 3 10 | }, 11 | stateKeys = { 12 | 0: 'valid', 13 | 1: 'empty', 14 | 2: 'warning', 15 | 3: 'error' 16 | }; 17 | 18 | return { 19 | selectPriorityState: function(states, type) { 20 | type = typeof type !== 'undefined' ? type : 'top'; 21 | 22 | if(states.length === 0) { 23 | return null; 24 | } 25 | 26 | // Ensure states are unique 27 | states = _.uniq(states); 28 | 29 | if(states.length === 1) { 30 | return states[0]; 31 | } 32 | 33 | // Map states to sort indices 34 | var ids =_.map(states, function(key) { 35 | return stateIds[key]; 36 | }); 37 | 38 | // Sort state indices 39 | ids = _.sortBy(ids, function(id) { return id; }); 40 | 41 | // Retrieve highest priority state 42 | var id; 43 | 44 | if(type === 'top') { 45 | id = ids[0]; 46 | } else if(type === 'bottom') { 47 | id = ids[ids.length - 1]; 48 | } else { 49 | console.warn('Unknown "type" provided for selectPriorityState()'); 50 | return null; 51 | } 52 | 53 | // Map id to state key 54 | return stateKeys[id]; 55 | } 56 | }; 57 | }); 58 | -------------------------------------------------------------------------------- /app/scripts/directives/button.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .directive('coButton', function(Utils) { 5 | return { 6 | restrict: 'E', 7 | scope: { 8 | self: '=coSelf', 9 | callback: '=coCallback', 10 | 11 | class: '@coClass', 12 | size: '@coSize', 13 | tooltip: '@coTooltip' 14 | }, 15 | templateUrl: 'directives/button.html', 16 | transclude: true, 17 | 18 | compile: function(element, attrs) { 19 | if(!Utils.isDefined(attrs.coSize)) { 20 | attrs.coSize = 'tiny'; 21 | } 22 | 23 | return this.link; 24 | }, 25 | controller: function($scope) { 26 | $scope.call = function() { 27 | if(typeof $scope.self === 'undefined' || $scope.self === null) { 28 | return $scope.callback(); 29 | } 30 | 31 | return $.proxy($scope.callback, $scope.self)(); 32 | }; 33 | 34 | $scope.click = function() { 35 | $scope.button.start(); 36 | 37 | $scope.call().then(function() { 38 | $scope.button.stop(); 39 | }, function() { 40 | $scope.button.stop(); 41 | }); 42 | }; 43 | }, 44 | link: function(scope, element) { 45 | var $button = $('button', element); 46 | 47 | $button.addClass(scope.class) 48 | .addClass(scope.size) 49 | .addClass('ladda-button') 50 | .css('display', ''); 51 | 52 | if(scope.class === 'secondary') { 53 | $button.attr('data-spinner-color', '#333333'); 54 | } 55 | 56 | scope.button = Ladda.create($button[0]); 57 | } 58 | }; 59 | }); 60 | -------------------------------------------------------------------------------- /app/scripts/models/authentication/trakt/basic.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .factory('TraktBasicAuthentication', function(BaseAuthentication, Utils, $q) { 5 | function TraktBasicAuthentication(main) { 6 | this.main = main; 7 | 8 | this.changed = false; 9 | this.original = null; 10 | 11 | // Authorization details 12 | this.username = null; 13 | this.password = null; 14 | 15 | // State 16 | this.messages = []; 17 | this.state = ''; 18 | } 19 | 20 | TraktBasicAuthentication.prototype.appendMessage = function(type, content) { 21 | this.messages.push({ 22 | type: type, 23 | content: content 24 | }); 25 | }; 26 | 27 | TraktBasicAuthentication.prototype.current = function() { 28 | return { 29 | username: this.username, 30 | 31 | authorization: { 32 | basic: { 33 | password: this.password 34 | } 35 | } 36 | }; 37 | }; 38 | 39 | TraktBasicAuthentication.prototype.update = function(data) { 40 | this.changed = false; 41 | this.original = angular.copy(data); 42 | 43 | // Authorization details 44 | this.username = data.username; 45 | this.password = data.password; 46 | 47 | // State 48 | this.messages = []; 49 | this.state = data.state; 50 | }; 51 | 52 | TraktBasicAuthentication.prototype.updateAuthorization = function() { 53 | // Clear messages 54 | this.messages = []; 55 | 56 | // Set `changed` flag 57 | this.changed = true; 58 | 59 | // Update messages 60 | this.appendMessage('info', "Account details are unavailable until changes have been saved"); 61 | }; 62 | 63 | return TraktBasicAuthentication; 64 | }); 65 | -------------------------------------------------------------------------------- /app/scripts/directives/button-input.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .directive('coButtonInput', function($timeout) { 5 | return { 6 | restrict: 'E', 7 | scope: { 8 | callback: '=coCallback', 9 | 10 | placeholder: '@coPlaceholder', 11 | tooltip: '@coTooltip' 12 | }, 13 | templateUrl: 'directives/button-input.html', 14 | transclude: true, 15 | 16 | controller: function($scope) { 17 | $scope.opened = false; 18 | $scope.value = null; 19 | 20 | $scope.click = function() { 21 | if(!$scope.opened) { 22 | $scope.open(); 23 | return; 24 | } 25 | 26 | $scope.button.start(); 27 | 28 | $scope.callback($scope.value).then(function() { 29 | $scope.close(); 30 | $scope.button.stop(); 31 | }, function(error) { 32 | $scope.error = error; 33 | 34 | $scope.button.stop(); 35 | }); 36 | }; 37 | 38 | $scope.open = function() { 39 | $scope.opened = true; 40 | 41 | $timeout(function() { 42 | $scope.input.focus(); 43 | }, 100); 44 | }; 45 | 46 | $scope.close = function() { 47 | $scope.error = null; 48 | $scope.opened = false; 49 | $scope.value = null; 50 | }; 51 | 52 | $scope.keyup = function(event) { 53 | if(event.keyCode === 13) { 54 | // ENTER 55 | $scope.click(); 56 | } else if(event.keyCode === 27) { 57 | // ESC 58 | $scope.close(); 59 | } 60 | }; 61 | }, 62 | link: function(scope, element) { 63 | scope.button = Ladda.create($('button', element)[0]); 64 | scope.input = $('input', element); 65 | } 66 | }; 67 | }); 68 | -------------------------------------------------------------------------------- /app/views/configuration/accounts.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
    4 |
  • 5 | 6 | {{ a.name }} 7 | 8 |
  • 9 |
10 | 11 |
12 |
13 | 14 | 15 | 16 |
17 | 18 |
19 | 20 | 21 | 22 |
23 |
24 |
25 | 26 |
27 | 34 | 35 | 36 |
37 |
38 |
39 |
Authentication
40 |
41 |
42 | 43 |
44 | {{ message.content }} 45 |
46 | 47 | 49 | 50 |
51 | 52 | 54 | 55 |
56 |
57 | -------------------------------------------------------------------------------- /app/scripts/controllers/connect.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * @ngdoc function 5 | * @name configurationApp.controller:ConnectController 6 | * @description 7 | * # ConnectController 8 | * Controller of the configurationApp 9 | */ 10 | angular.module('configurationApp') 11 | .controller('ConnectController', function(PlexServer, RavenTags, $location, $rootScope, $scope) { 12 | $scope.state = ''; 13 | $scope.selected = null; 14 | 15 | $scope.servers = []; 16 | 17 | plex.cloud['/api'].resources(true).then(function(response) { 18 | // Retrieve devices 19 | var data = response.data, 20 | devices = data.MediaContainer.Device; 21 | 22 | if(typeof devices.length === 'undefined') { 23 | devices = [devices]; 24 | } 25 | 26 | // Filter devices to servers only 27 | var servers = _.filter(devices, function(device) { 28 | return device._provides === 'server'; 29 | }); 30 | 31 | $scope.$apply(function() { 32 | // Build `Server` objects 33 | $scope.servers = _.map(servers, function (server) { 34 | return PlexServer.fromElement(server); 35 | }); 36 | }); 37 | }); 38 | 39 | $scope.select = function(server) { 40 | server.connect().then(function() { 41 | console.log('Connection successful'); 42 | 43 | server.authenticate().then(function() { 44 | console.log('Authentication successful'); 45 | 46 | // Update current server 47 | $rootScope.$s = server; 48 | 49 | // Update raven tags 50 | RavenTags.update({ 51 | plugin_version: server.plugin_version 52 | }); 53 | 54 | // Redirect to original view 55 | $scope.$r.redirect(); 56 | }, function() { 57 | console.warn('Authentication failed'); 58 | 59 | $scope.state = ''; 60 | }); 61 | }, function() { 62 | console.warn('Unable to find valid connection'); 63 | 64 | $scope.state = ''; 65 | }); 66 | 67 | $scope.state = 'connecting'; 68 | $scope.selected = server; 69 | }; 70 | }); 71 | -------------------------------------------------------------------------------- /app/scripts/models/authentication/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .factory('AccountAuthentication', function(BaseAuthentication, PlexAuthentication, TraktAuthentication, Utils) { 5 | function AccountAuthentication() { 6 | this.plex = new PlexAuthentication(); 7 | this.trakt = new TraktAuthentication(); 8 | 9 | // State 10 | this.messages = []; 11 | this.state = ''; 12 | } 13 | 14 | AccountAuthentication.prototype.appendMessage = function(type, content) { 15 | this.messages.push({ 16 | type: type, 17 | content: content 18 | }); 19 | }; 20 | 21 | AccountAuthentication.prototype.check = function() { 22 | this.plex.check(); 23 | this.trakt.check(); 24 | 25 | // Retrieve message states 26 | var states = _.map(this.messages, function(message) { 27 | return message.type; 28 | }); 29 | 30 | // Extend `states` with authentication states 31 | states.push(this.plex.state); 32 | states.push(this.trakt.state); 33 | 34 | // Select highest severity state 35 | this.state = BaseAuthentication.selectPriorityState(states, 'bottom'); 36 | }; 37 | 38 | AccountAuthentication.prototype.clear = function() { 39 | // Clear messages 40 | this.messages = []; 41 | 42 | // Clear children 43 | this.plex.clear(); 44 | this.trakt.clear(); 45 | 46 | // Update state 47 | this.check(); 48 | }; 49 | 50 | AccountAuthentication.prototype.update = function(data) { 51 | // Update handlers 52 | this.plex.update(data.plex); 53 | this.trakt.update(data.trakt); 54 | 55 | // Reset state 56 | this.messages = []; 57 | this.state = ''; 58 | 59 | // Check current authentication 60 | this.check(); 61 | }; 62 | 63 | AccountAuthentication.prototype.onSaveError = function(error) { 64 | if(!Utils.isDefined(error) || !Utils.isDefined(error.message)) { 65 | return; 66 | } 67 | 68 | // Store error message 69 | this.appendMessage('error', error.message); 70 | }; 71 | 72 | return AccountAuthentication; 73 | }); 74 | -------------------------------------------------------------------------------- /app/directives/plex/home.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
Select User
5 |
6 | 7 |
    8 |
  • 9 | 10 |
    11 | 12 | 13 |
    14 | 15 | 16 |
    17 |
    18 | 19 | {{ user._title }} 20 |
    21 |
  • 22 |
23 |
24 | 25 |
26 |
27 | 28 |
Enter PIN
29 |
30 | 31 |
32 |
33 | 34 | 35 |
36 | 37 | 38 |
39 |
40 | 41 | {{ current._title }} 42 |
43 | 44 |
45 |
46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 |
59 |
60 |
61 |
62 | -------------------------------------------------------------------------------- /app/directives/trakt/login.html: -------------------------------------------------------------------------------- 1 | {{ message.content }} 2 | 3 |
4 |
5 |
6 | 9 | 12 |
13 | 14 |
15 |
16 | 21 | Cancel 22 | 23 | Mode 24 |
25 | 26 | Sign in 27 |
28 |
29 | 30 |
31 |
32 | 33 | 34 |
35 | 36 |
37 |
38 | 43 | Cancel 44 | 45 | Mode 46 |
47 | 48 | Sign in 49 |
50 |
51 |
52 | 53 |
    54 |
  • 55 | Basic 56 |
  • 57 |
  • 58 | PIN 59 |
  • 60 |
61 | -------------------------------------------------------------------------------- /app/styles/main.scss: -------------------------------------------------------------------------------- 1 | // bower:scss 2 | // endbower 3 | 4 | @import 'mixins/no-select'; 5 | 6 | @import 'components/dropdown'; 7 | @import 'components/message'; 8 | 9 | @import 'configuration/base'; 10 | @import 'configuration/accounts'; 11 | @import 'configuration/server'; 12 | @import 'configuration/rules'; 13 | 14 | @import 'directives/configuration/header'; 15 | @import 'directives/configuration/option'; 16 | @import 'directives/plex/home'; 17 | @import 'directives/plex/login'; 18 | @import 'directives/plex/pin'; 19 | @import 'directives/trakt/login'; 20 | @import 'directives/button-input'; 21 | 22 | @import 'modals/deleteAccount'; 23 | 24 | @import 'connect'; 25 | @import 'login'; 26 | 27 | .browsehappy { 28 | margin: 0.2em 0; 29 | background: #ccc; 30 | color: #000; 31 | padding: 0.2em 0; 32 | } 33 | 34 | [ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak { 35 | display: none !important; 36 | } 37 | 38 | body { 39 | padding: 0; 40 | } 41 | 42 | button, a { 43 | outline: none; 44 | } 45 | 46 | button.secondary:focus { 47 | background-color: #e7e7e7; 48 | } 49 | 50 | button.secondary:hover { 51 | background-color: #b9b9b9; 52 | } 53 | 54 | button:focus { 55 | background-color: #008CBA; 56 | } 57 | 58 | button:hover { 59 | background-color: #007095; 60 | } 61 | 62 | .scroll-container { 63 | max-width: 1280px; 64 | 65 | margin-left: auto; 66 | margin-right: auto; 67 | } 68 | 69 | .footer { 70 | position: fixed; 71 | bottom: 0; 72 | left: 0; 73 | 74 | padding: 5px; 75 | 76 | font-size: 10px; 77 | 78 | i { 79 | font-size: 14px; 80 | 81 | vertical-align: middle; 82 | 83 | color: #CCC; 84 | } 85 | 86 | a:hover i, a.active i { 87 | color: #888; 88 | } 89 | 90 | .extra + .extra { 91 | margin-top: 3px; 92 | } 93 | 94 | .extra.connection .extended { 95 | margin-left: 1px; 96 | } 97 | 98 | .extended { 99 | display: inline-block; 100 | } 101 | } 102 | 103 | @-webkit-keyframes scaleout { 104 | 0% { -webkit-transform: scale(0.0) } 105 | 100% { 106 | -webkit-transform: scale(1.0); 107 | opacity: 0; 108 | } 109 | } 110 | 111 | @keyframes scaleout { 112 | 0% { 113 | transform: scale(0.0); 114 | -webkit-transform: scale(0.0); 115 | } 100% { 116 | transform: scale(1.0); 117 | -webkit-transform: scale(1.0); 118 | opacity: 0; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /app/styles/directives/plex/home.scss: -------------------------------------------------------------------------------- 1 | @import '../../mixins/no-select'; 2 | 3 | .accounts .authentication co-plex-home .avatar { 4 | float: none; 5 | 6 | width: 60px; 7 | height: 60px; 8 | 9 | margin-right: 0; 10 | 11 | box-shadow: 0 0 0 4px #AAA; 12 | } 13 | 14 | co-plex-home { 15 | h5, h6 { 16 | line-height: 1.3 !important; 17 | margin-bottom: 10px !important; 18 | } 19 | 20 | .header { 21 | overflow: auto; 22 | 23 | a { 24 | float: left; 25 | } 26 | 27 | h5, h6 { 28 | float: left; 29 | } 30 | 31 | a.back i { 32 | margin-right: 3px; 33 | 34 | color: rgba(0, 0, 0, 0.4); 35 | 36 | font-size: 20px; 37 | line-height: 1.1; 38 | } 39 | 40 | a.back:hover i { 41 | color: rgba(0, 0, 0, 0.6); 42 | } 43 | } 44 | 45 | .user { 46 | margin: 0 auto; 47 | } 48 | 49 | .tile { 50 | position: relative; 51 | 52 | padding: 24px 0; 53 | 54 | background-color: rgba(0, 0, 0, 0.12); 55 | border-radius: 5px 5px 0 0; 56 | 57 | text-align: center; 58 | } 59 | 60 | .title { 61 | display: block; 62 | height: 30px; 63 | 64 | padding: 6px 0 0; 65 | 66 | background-color: #555; 67 | border-radius: 0 0 5px 5px; 68 | 69 | color: #BBB; 70 | font-size: 12px; 71 | text-align: center; 72 | text-overflow: ellipsis; 73 | 74 | overflow: hidden; 75 | white-space: nowrap; 76 | } 77 | 78 | .tags { 79 | position: absolute; 80 | left: 6px; 81 | right: 6px; 82 | bottom: 6px; 83 | 84 | overflow: auto; 85 | 86 | i { 87 | width: 25px; 88 | 89 | background-color: #F3F3F3; 90 | border-radius: 50%; 91 | } 92 | 93 | i.admin { 94 | float: right; 95 | 96 | color: #DFA700; 97 | } 98 | 99 | i.protected { 100 | float: left; 101 | 102 | color: #00A925; 103 | } 104 | } 105 | 106 | .pin-authentication { 107 | padding-bottom: 20px; 108 | 109 | .user { 110 | padding-left: 0; 111 | } 112 | 113 | .fields { 114 | span { 115 | padding: 0 5px; 116 | } 117 | 118 | input[type="password"] { 119 | height: 110px; 120 | 121 | margin-top: 9px; 122 | 123 | font-size: 70px; 124 | text-align: center; 125 | } 126 | } 127 | } 128 | 129 | a.user:hover { 130 | .avatar { 131 | box-shadow: 0 0 0 4px #FFF; 132 | } 133 | 134 | .title { 135 | color: #FFF; 136 | } 137 | } 138 | 139 | .user, 140 | .tile, 141 | .title { 142 | @include no-select(); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /app/directives/plex/login.html: -------------------------------------------------------------------------------- 1 | {{ message.content }} 2 | 3 |
4 |
5 |
6 | 9 | 12 |
13 | 14 |
15 |
16 | Cancel 17 | Mode 18 |
19 | 20 | Sign in 21 |
22 |
23 | 24 |
25 | 27 | 28 | 29 |
30 |
31 | 36 | Cancel 37 | 38 | Mode 39 |
40 |
41 |
42 | 43 |
44 |
45 | Visit https://plex.tv/pin and enter this code: 46 |
47 | 48 | 51 | 52 | 53 |
54 |
55 | 60 | Cancel 61 | 62 | Mode 63 |
64 |
65 |
66 |
67 | 68 |
    69 |
  • 70 | Basic 71 |
  • 72 |
  • 73 | Home 74 |
  • 75 |
  • 76 | PIN 77 |
  • 78 |
79 | -------------------------------------------------------------------------------- /app/scripts/directives/authentication/plex.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .directive('coAuthenticationPlex', function(Utils, $modal, $rootScope) { 5 | return { 6 | restrict: 'E', 7 | scope: { 8 | account: '=coAccount', 9 | plex: '=coPlex' 10 | }, 11 | templateUrl: 'directives/authentication/plex.html', 12 | 13 | controller: function($scope, $timeout) { 14 | $scope._state = null; 15 | 16 | $scope.isAuthenticated = function() { 17 | return !!( 18 | Utils.isDefined($scope.plex.title) && 19 | $scope.plex.title.length !== 0 20 | ); 21 | }; 22 | 23 | $scope.state = function(value) { 24 | if(Utils.isDefined(value)) { 25 | $scope._state = value; 26 | return; 27 | } 28 | 29 | if(!Utils.isDefined($scope.plex)) { 30 | // Not initialized yet 31 | return 'view'; 32 | } 33 | 34 | if(!$scope.isAuthenticated()) { 35 | // Account hasn't been authenticated yet 36 | return 'edit'; 37 | } 38 | 39 | if(Utils.isDefined($scope._state)) { 40 | return $scope._state; 41 | } 42 | 43 | return 'view'; 44 | }; 45 | 46 | $scope.disconnect = function() { 47 | // Create new scope for modal 48 | var scope = $scope.$new(true); 49 | scope.account = $scope.account; 50 | scope.username = $scope.plex.title; 51 | 52 | // Create modal 53 | var modal = $modal.open({ 54 | templateUrl: 'modals/disconnectAccount.html', 55 | windowClass: 'small', 56 | scope: scope 57 | }); 58 | 59 | // Display modal, wait for result 60 | return modal.result.then(function() { 61 | // Delete plex account on server 62 | return $scope.plex.delete($rootScope.$s).then(function() { 63 | $rootScope.$broadcast('account.plex.deleted'); 64 | }); 65 | }); 66 | }; 67 | 68 | $scope.switch = function(state) { 69 | $scope.state(state); 70 | }; 71 | 72 | $scope.onAuthenticated = function(token, user) { 73 | $scope.plex.updateAuthorization(token, user); 74 | $scope.state('view'); 75 | }; 76 | 77 | $scope.onCancelled = function() { 78 | $scope.state('view'); 79 | }; 80 | 81 | // Watch for account changes 82 | $scope.$watch( 83 | function(scope) { return scope.plex; }, 84 | function() { 85 | $scope._state = null; 86 | 87 | // Broadcast events 88 | $scope.$broadcast('reset'); 89 | 90 | $timeout(function() { 91 | $scope.$broadcast('activate'); 92 | }); 93 | } 94 | ); 95 | } 96 | }; 97 | }); 98 | -------------------------------------------------------------------------------- /app/scripts/directives/configuration/option.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .directive('coConfigurationOption', function() { 5 | var descriptionBottomOffset = 20; 6 | 7 | return { 8 | restrict: 'E', 9 | scope: { 10 | option: '=' 11 | }, 12 | template: '', 13 | controller: function($scope, $element) { 14 | var baseUrl = 'directives/configuration/option/', 15 | templateMap = { 16 | boolean: 'boolean.html', 17 | enum: 'enum.html', 18 | integer: 'integer.html', 19 | string: 'string.html' 20 | }; 21 | 22 | $scope.descriptionOpened = false; 23 | 24 | $scope.getTemplateUrl = function() { 25 | return baseUrl + templateMap[$scope.option.type]; 26 | }; 27 | 28 | $scope.parseKey = function(id) { 29 | if(id === 'null') { 30 | return null; 31 | } 32 | 33 | // Try parse integer from string 34 | var number = parseInt(id, 10); 35 | 36 | if(!isNaN(number) && number.toString() === id) { 37 | return number; 38 | } 39 | 40 | // Return original string 41 | return id; 42 | }; 43 | 44 | $scope.closeDescription = function() { 45 | $scope.descriptionOpened = false; 46 | }; 47 | 48 | $scope.openDescription = function() { 49 | $($element).css('top', ''); 50 | 51 | $scope.descriptionOpened = true; 52 | }; 53 | }, 54 | link: function(scope, element, attrs) { 55 | var $description = null; 56 | 57 | $(element).hover(function() { 58 | if($description == null) { 59 | $description = $('.description', element); 60 | } 61 | 62 | var documentHeight = $(document).height(); 63 | 64 | // Open description 65 | $description.addClass('visible'); 66 | 67 | if($description.css('display') != 'flex') { 68 | // Small screens (description is displayed in a modal) 69 | $description.removeClass('visible'); 70 | return; 71 | } 72 | 73 | // Position description (ensure it is within the document height) 74 | var offset = $description.offset(), 75 | bottom = offset.top + $description.height() + descriptionBottomOffset, 76 | hidden = bottom - documentHeight; 77 | 78 | if(hidden > 0) { 79 | $description.css('top', (-hidden) + 'px'); 80 | } 81 | }, function() { 82 | if($description == null) { 83 | $description = $('.description', element); 84 | } 85 | 86 | // Reset offset 87 | $description.css('top', ''); 88 | 89 | // Close description 90 | $description.removeClass('visible'); 91 | }) 92 | } 93 | }; 94 | }); 95 | -------------------------------------------------------------------------------- /app/scripts/models/configuration/rules/user/rule.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .factory('UserRule', function() { 5 | function UserRule(collection, data, state) { 6 | this.collection = collection; 7 | 8 | this.id = null; 9 | 10 | this.name = null; 11 | 12 | this.account = null; 13 | this.priority = null; 14 | 15 | this.state = typeof state !== 'undefined' ? state: 'view'; 16 | 17 | this.update(data); 18 | } 19 | 20 | UserRule.prototype.delete = function() { 21 | this.collection.delete(this); 22 | }; 23 | 24 | UserRule.prototype.edit = function() { 25 | this.collection.reset(); 26 | 27 | this.state = 'edit'; 28 | }; 29 | 30 | UserRule.prototype.focus = function() { 31 | if(typeof this.priority === 'undefined' || this.priority === null) { 32 | return; 33 | } 34 | 35 | var $tr = $('.rules .users .rule.p-' + this.priority); 36 | 37 | if($tr === null || $tr.length === 0) { 38 | console.warn('Unable to find TR element for rule', this); 39 | return; 40 | } 41 | 42 | console.log($tr); 43 | 44 | $('td.account input', $tr).focus(); 45 | }; 46 | 47 | UserRule.prototype.save = function() { 48 | this.state = 'view'; 49 | 50 | // Update account name 51 | var account = this.collection.accountsById[this.account.id]; 52 | 53 | if(typeof account === 'undefined') { 54 | this.account.name = 'None'; 55 | } else { 56 | this.account.name = account.text; 57 | } 58 | }; 59 | 60 | function parseAccount(data) { 61 | if(data.account_function === '@') { 62 | return { 63 | id: '@', 64 | name: 'Map' 65 | } 66 | } 67 | 68 | if(typeof data.account !== 'undefined' && data.account !== null) { 69 | return data.account; 70 | } 71 | 72 | return { 73 | id: '-', 74 | name: 'None' 75 | }; 76 | } 77 | 78 | function attributeValue(value) { 79 | if(value === null) { 80 | // Any 81 | return '*'; 82 | } 83 | 84 | return value; 85 | } 86 | 87 | UserRule.prototype.current = function() { 88 | var data = { 89 | id: this.id, 90 | 91 | name: this.name, 92 | 93 | account: null, 94 | account_function: null, 95 | 96 | priority: this.priority 97 | }; 98 | 99 | // Parse account field 100 | if(this.account.id === '-') { 101 | data['account'] = null; 102 | } else if(this.account.id === '@') { 103 | data['account_function'] = '@'; 104 | } else { 105 | data['account'] = this.account.id; 106 | } 107 | 108 | return data; 109 | }; 110 | 111 | UserRule.prototype.update = function(data) { 112 | this.original = angular.copy(data); 113 | 114 | this.id = typeof data.id !== 'undefined' ? data.id : null; 115 | 116 | this.name = attributeValue(data.name); 117 | 118 | this.account = parseAccount(data); 119 | this.priority = data.priority; 120 | }; 121 | 122 | return UserRule; 123 | }); 124 | -------------------------------------------------------------------------------- /app/scripts/models/options.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .factory('Options', function (StringUtil, $q) { 5 | function parse(groups, options, depth) { 6 | depth = typeof depth !== 'undefined' ? depth : 0; 7 | 8 | for(var i = 0; i < options.length; ++i) { 9 | var option = options[i], 10 | groupName = option.group[depth], 11 | group = groups[groupName]; 12 | 13 | if(typeof group === 'undefined') { 14 | group = groups[groupName] = { 15 | name: groupName, 16 | groups: {}, 17 | 18 | enabled: null, 19 | options: [], 20 | 21 | order: null 22 | }; 23 | } 24 | 25 | // Update group order 26 | if(group.order === null) { 27 | group.order = option.order; 28 | } else if(option.order < group.order) { 29 | group.order = option.order; 30 | } 31 | 32 | // Update group options 33 | if(option.group.length - depth === 1) { 34 | if(StringUtil.endsWith(option.key, '.enabled') && option.type === 'boolean') { 35 | group.enabled = option; 36 | } else { 37 | group.options.push(option); 38 | } 39 | } else { 40 | parse(group.groups, [option], depth + 1); 41 | } 42 | } 43 | } 44 | 45 | function Options(options, account) { 46 | this.current = options; 47 | this.account = typeof account !== 'undefined' ? account : null; 48 | 49 | this.original = angular.copy(options); 50 | 51 | // Parse options 52 | this.groups = {}; 53 | 54 | parse(this.groups, options); 55 | } 56 | 57 | Options.prototype.discard = function() { 58 | for(var i = 0; i < this.current.length; ++i) { 59 | var c = this.current[i], 60 | o = this.original[i]; 61 | 62 | c.value = o.value; 63 | } 64 | 65 | return $q.resolve(); 66 | }; 67 | 68 | Options.prototype.save = function(server) { 69 | var self = this, 70 | changes = {}; 71 | 72 | for(var i = 0; i < this.current.length; ++i) { 73 | var c = this.current[i], 74 | o = this.original[i]; 75 | 76 | changes[c.key] = { 77 | from: o.value, 78 | to: c.value 79 | }; 80 | } 81 | 82 | return server.call('option.update', [], { 83 | changes: changes, 84 | account: this.account !== null ? this.account.id : null 85 | }).then(function(result) { 86 | // Build object of updated options 87 | var updated = _.object( 88 | _.map( 89 | result.updated, 90 | function(key) { 91 | return [key, true]; 92 | } 93 | ) 94 | ); 95 | 96 | // Update `original` option values to match the update 97 | _.each(self.original, function(option, index) { 98 | if(typeof updated[option.key] === 'undefined') { 99 | return; 100 | } 101 | 102 | option.value = self.current[index].value; 103 | }); 104 | }, function(error) { 105 | return $q.reject(error); 106 | }); 107 | }; 108 | 109 | return Options; 110 | }); 111 | -------------------------------------------------------------------------------- /app/scripts/models/configuration/rules/client/rule.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .factory('ClientRule', function() { 5 | function ClientRule(collection, data, state) { 6 | this.collection = collection; 7 | 8 | this.id = null; 9 | 10 | this.key = null; 11 | this.name = null; 12 | this.address = null; 13 | 14 | this.account = null; 15 | this.priority = null; 16 | 17 | this.state = typeof state !== 'undefined' ? state: 'view'; 18 | 19 | this.update(data); 20 | } 21 | 22 | ClientRule.prototype.delete = function() { 23 | this.collection.delete(this); 24 | }; 25 | 26 | ClientRule.prototype.edit = function() { 27 | this.collection.reset(); 28 | 29 | this.state = 'edit'; 30 | }; 31 | 32 | ClientRule.prototype.focus = function() { 33 | if(typeof this.priority === 'undefined' || this.priority === null) { 34 | return; 35 | } 36 | 37 | var $tr = $('.rules .clients .rule.p-' + this.priority); 38 | 39 | if($tr === null || $tr.length === 0) { 40 | console.warn('Unable to find TR element for rule', this); 41 | return; 42 | } 43 | 44 | console.log($tr); 45 | 46 | $('td.account input', $tr).focus(); 47 | }; 48 | 49 | ClientRule.prototype.save = function() { 50 | this.state = 'view'; 51 | 52 | // Update account name 53 | var account = this.collection.accountsById[this.account.id]; 54 | 55 | if(typeof account === 'undefined') { 56 | this.account.name = 'None'; 57 | } else { 58 | this.account.name = account.text; 59 | } 60 | }; 61 | 62 | function parseAccount(data) { 63 | if(data.account_function === '@') { 64 | return { 65 | id: '@', 66 | name: 'Map' 67 | } 68 | } 69 | 70 | if(typeof data.account !== 'undefined' && data.account !== null) { 71 | return data.account; 72 | } 73 | 74 | return { 75 | id: '-', 76 | name: 'None' 77 | }; 78 | } 79 | 80 | function attributeValue(value) { 81 | if(value === null) { 82 | // Any 83 | return '*'; 84 | } 85 | 86 | return value; 87 | } 88 | 89 | ClientRule.prototype.current = function() { 90 | var data = { 91 | id: this.id, 92 | 93 | key: this.key, 94 | name: this.name, 95 | address: this.address, 96 | 97 | account: null, 98 | account_function: null, 99 | 100 | priority: this.priority 101 | }; 102 | 103 | // Parse account field 104 | if(this.account.id === '-') { 105 | data['account'] = null; 106 | } else if(this.account.id === '@') { 107 | data['account_function'] = '@'; 108 | } else { 109 | data['account'] = this.account.id; 110 | } 111 | 112 | return data; 113 | }; 114 | 115 | ClientRule.prototype.update = function(data) { 116 | this.original = angular.copy(data); 117 | 118 | this.id = typeof data.id !== 'undefined' ? data.id : null; 119 | 120 | this.key = attributeValue(data.key); 121 | this.name = attributeValue(data.name); 122 | this.address = attributeValue(data.address); 123 | 124 | this.account = parseAccount(data); 125 | this.priority = data.priority; 126 | }; 127 | 128 | return ClientRule; 129 | }); 130 | -------------------------------------------------------------------------------- /app/scripts/directives/authentication/trakt.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .directive('coAuthenticationTrakt', function(Utils, $modal, $rootScope) { 5 | return { 6 | restrict: 'E', 7 | scope: { 8 | account: '=coAccount', 9 | trakt: '=coTrakt' 10 | }, 11 | templateUrl: 'directives/authentication/trakt.html', 12 | 13 | controller: function($scope) { 14 | $scope._state = null; 15 | 16 | $scope.isAuthenticated = function() { 17 | return !!( 18 | Utils.isDefined($scope.trakt.username) && 19 | $scope.trakt.username.length !== 0 20 | ); 21 | }; 22 | 23 | $scope.state = function(value) { 24 | if(Utils.isDefined(value)) { 25 | $scope._state = value; 26 | return; 27 | } 28 | 29 | if(!Utils.isDefined($scope.trakt)) { 30 | // Not initialized yet 31 | return 'view'; 32 | } 33 | 34 | if(!$scope.isAuthenticated()) { 35 | // Account hasn't been authenticated yet 36 | return 'edit'; 37 | } 38 | 39 | if(Utils.isDefined($scope._state)) { 40 | return $scope._state; 41 | } 42 | 43 | return 'view'; 44 | }; 45 | 46 | $scope.disconnect = function() { 47 | // Create new scope for modal 48 | var scope = $scope.$new(true); 49 | scope.account = $scope.account; 50 | scope.username = $scope.trakt.username; 51 | 52 | // Create modal 53 | var modal = $modal.open({ 54 | templateUrl: 'modals/disconnectAccount.html', 55 | windowClass: 'small', 56 | scope: scope 57 | }); 58 | 59 | // Display modal, wait for result 60 | return modal.result.then(function() { 61 | // Delete trakt account on server 62 | return $scope.trakt.delete($rootScope.$s).then(function() { 63 | $rootScope.$broadcast('account.trakt.deleted'); 64 | }); 65 | }); 66 | }; 67 | 68 | $scope.switch = function(state) { 69 | // Change view state 70 | $scope.state(state); 71 | }; 72 | 73 | $scope.onBasicAuthenticated = function() { 74 | // Clear messages 75 | $scope.messages = []; 76 | 77 | // Update account details 78 | $scope.trakt.basic.updateAuthorization(); 79 | $scope.trakt.updateDetails(); 80 | 81 | // Change view state 82 | $scope.state('view'); 83 | }; 84 | 85 | $scope.onPinAuthenticated = function(authorization, settings) { 86 | // Update account details 87 | $scope.trakt.pin.updateAuthorization(authorization); 88 | $scope.trakt.updateDetails(settings); 89 | 90 | // Change view state 91 | $scope.state('view'); 92 | }; 93 | 94 | $scope.onCancelled = function() { 95 | // Change view state 96 | $scope.state('view'); 97 | }; 98 | 99 | // Watch for account changes 100 | $scope.$watch( 101 | function(scope) { return scope.trakt; }, 102 | function() { 103 | $scope._state = null; 104 | 105 | // Broadcast reset event to child directives 106 | $scope.$broadcast('reset'); 107 | } 108 | ); 109 | } 110 | }; 111 | }); 112 | -------------------------------------------------------------------------------- /app/scripts/models/authentication/trakt/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .factory('TraktAuthentication', function(TraktBasicAuthentication, TraktPinAuthentication, BaseAuthentication, Utils) { 5 | function TraktAuthentication() { 6 | this.original = null; 7 | 8 | // Account details 9 | this.id = null; 10 | this.username = null; 11 | this.thumb_url = null; 12 | 13 | this.basic = new TraktBasicAuthentication(this); 14 | this.pin = new TraktPinAuthentication(this); 15 | 16 | // State 17 | this.messages = []; 18 | this.state = ''; 19 | } 20 | 21 | TraktAuthentication.prototype.appendMessage = function(type, content) { 22 | this.messages.push({ 23 | type: type, 24 | content: content 25 | }); 26 | }; 27 | 28 | TraktAuthentication.prototype.check = function() { 29 | // Retrieve message states 30 | var states = _.map(this.messages, function(message) { 31 | return message.type; 32 | }); 33 | 34 | // Extend `states` with authentication state 35 | states.push(BaseAuthentication.selectPriorityState([ 36 | this.basic.state, 37 | this.pin.state 38 | ])); 39 | 40 | // Select highest severity state 41 | this.state = BaseAuthentication.selectPriorityState(states, 'bottom'); 42 | }; 43 | 44 | TraktAuthentication.prototype.clear = function(data) { 45 | // Clear messages 46 | this.messages = []; 47 | 48 | // Update state 49 | this.check(); 50 | }; 51 | 52 | TraktAuthentication.prototype.current = function() { 53 | var data = { 54 | trakt: {} 55 | }; 56 | 57 | // Basic 58 | if(this.basic.changed) { 59 | $.extend(true, data.trakt, this.basic.current()); 60 | } 61 | 62 | // PIN 63 | if(this.pin.changed) { 64 | $.extend(true, data.trakt, this.pin.current()); 65 | } 66 | 67 | return data; 68 | }; 69 | 70 | TraktAuthentication.prototype.delete = function(server) { 71 | var self = this; 72 | 73 | return server.call('account.trakt.delete', [], {id: self.id}).then(function(success) { 74 | if(!success) { 75 | return $q.reject(); 76 | } 77 | }); 78 | }; 79 | 80 | TraktAuthentication.prototype.update = function(data) { 81 | this.original = angular.copy(data); 82 | 83 | // Account details 84 | this.id = data.id; 85 | this.username = data.username; 86 | this.thumb_url = data.thumb_url; 87 | 88 | this.basic.update(data.authorization.basic); 89 | this.pin.update(data.authorization.oauth); 90 | 91 | // Reset state 92 | this.messages = []; 93 | this.state = ''; 94 | }; 95 | 96 | TraktAuthentication.prototype.updateDetails = function(settings) { 97 | if(!Utils.isDefined(settings) || !Utils.isDefined(settings.user)) { 98 | this.username = 'Unknown'; 99 | this.thumb_url = null; 100 | 101 | // TODO Add message to indicate a save is required (for basic authorization) 102 | return; 103 | } 104 | 105 | var avatar = settings.user.images.avatar, 106 | user = settings.user; 107 | 108 | this.username = user.username; 109 | this.thumb_url = avatar.full; 110 | }; 111 | 112 | TraktAuthentication.prototype.onSaveError = function(error) { 113 | if(!Utils.isDefined(error) || !Utils.isDefined(error.message)) { 114 | return; 115 | } 116 | 117 | // Store error message 118 | this.appendMessage('error', error.message); 119 | }; 120 | 121 | return TraktAuthentication; 122 | }); 123 | -------------------------------------------------------------------------------- /app/scripts/models/plex/connection_manager.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .factory('PlexConnectionManager', function($http, $q) { 5 | function PlexConnectionManager(server, connections) { 6 | this.server = server; 7 | this.available = connections; 8 | 9 | this.client = null; 10 | this.current = null; 11 | } 12 | 13 | PlexConnectionManager.prototype.reset = function() { 14 | // Update server 15 | this.client = null; 16 | this.current = null; 17 | 18 | this.server.client = null; 19 | }; 20 | 21 | PlexConnectionManager.prototype.test = function() { 22 | var self = this, 23 | connections = angular.copy(self.available), 24 | deferred = $q.defer(), 25 | error = 'Unable to find valid connection'; 26 | 27 | console.debug('[%s] Testing %s connections', self.server.identifier, connections.length); 28 | 29 | function testOne() { 30 | var connection = connections.shift(); 31 | 32 | if(connection == null) { 33 | deferred.reject(error); 34 | return; 35 | } 36 | 37 | self.testConnection(connection).then(function(connection) { 38 | deferred.resolve(connection); 39 | }, function(connectionError) { 40 | if(typeof connectionError !== 'undefined' && connectionError !== null) { 41 | error = connectionError; 42 | } 43 | 44 | testOne(); 45 | }); 46 | } 47 | 48 | // Reset current connection details 49 | self.reset(); 50 | 51 | // Start testing connections 52 | testOne(); 53 | 54 | return deferred.promise; 55 | }; 56 | 57 | PlexConnectionManager.prototype.testConnection = function(connection) { 58 | var self = this, 59 | client = new plex.Server(connection.uri); 60 | 61 | console.debug('[%s] Testing connection: %s', self.server.identifier, connection.uri); 62 | 63 | // Setup client 64 | client.client_identifier = localStorage['plex.client.identifier']; 65 | client.token = self.server.token_plex; 66 | 67 | client.http.headers.setProduct('trakt (for Plex) - Configuration', '1.0.0'); 68 | client.http.xmlParser = 'x2js'; 69 | 70 | // Ensure we don't attempt an http:// connection while https:// is being used 71 | if(window.location.protocol === 'https:' && connection.uri.startsWith('http:')) { 72 | return $q.reject("Only secure servers are supported when browsing over https://"); 73 | } 74 | 75 | // Test connection 76 | var deferred = $q.defer(); 77 | 78 | client.identity({ 79 | timeout: 1500 80 | }).then(function(response) { 81 | var data = response.data, 82 | connectionIdentifier = data.MediaContainer._machineIdentifier; 83 | 84 | if(connectionIdentifier !== self.server.identifier) { 85 | console.debug("Connection identifier %s doesn't match the server identifier %s", connectionIdentifier, self.server.identifier); 86 | deferred.reject(); 87 | return; 88 | } 89 | 90 | console.log('[%s] Using connection: %s', self.server.identifier, connection.uri); 91 | 92 | // Update connection 93 | connection.client = client; 94 | 95 | // Update server 96 | self.client = client; 97 | self.current = connection; 98 | 99 | self.server.client = client; 100 | 101 | // Resolve promise 102 | deferred.resolve(connection); 103 | }, function() { 104 | deferred.reject(); 105 | }); 106 | 107 | return deferred.promise; 108 | }; 109 | 110 | return PlexConnectionManager; 111 | }); 112 | -------------------------------------------------------------------------------- /app/scripts/models/authentication/plex.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .factory('PlexAuthentication', function(BaseAuthentication, Utils, $q) { 5 | function PlexAuthentication() { 6 | this.changed = false; 7 | this.original = null; 8 | 9 | // Account details 10 | this.id = null; 11 | this.username = null; 12 | 13 | this.title = null; 14 | this.thumb_url = null; 15 | 16 | // Authorization details 17 | this.authorization = null; 18 | 19 | // State 20 | this.messages = []; 21 | this.state = ''; 22 | } 23 | 24 | PlexAuthentication.prototype.appendMessage = function(type, content) { 25 | this.messages.push({ 26 | type: type, 27 | content: content 28 | }); 29 | }; 30 | 31 | PlexAuthentication.prototype.check = function() { 32 | if((!Utils.isDefined(this.title) || 33 | this.title.length === 0) && 34 | this.authorization.basic.state === 'valid') { 35 | // Update warnings 36 | this.appendMessage('warning', "Account hasn't completed the authentication process"); 37 | 38 | // Update state 39 | this.authorization.basic.state = 'warning'; 40 | } 41 | 42 | // Retrieve message states 43 | var states = _.map(this.messages, function(message) { 44 | return message.type; 45 | }); 46 | 47 | // Extend `states` with authentication states 48 | states.push(this.authorization.basic.state); 49 | 50 | // Update state 51 | this.state = BaseAuthentication.selectPriorityState(states, 'bottom'); 52 | }; 53 | 54 | PlexAuthentication.prototype.clear = function(data) { 55 | // Clear error messages 56 | this.messages = []; 57 | 58 | // Update state 59 | this.check(); 60 | }; 61 | 62 | PlexAuthentication.prototype.current = function() { 63 | if(!this.changed) { 64 | return {}; 65 | } 66 | 67 | return { 68 | plex: { 69 | username: this.username, 70 | 71 | authorization: { 72 | basic: { 73 | token_plex: this.authorization.basic.token_plex 74 | } 75 | } 76 | } 77 | }; 78 | }; 79 | 80 | PlexAuthentication.prototype.delete = function(server) { 81 | var self = this; 82 | 83 | return server.call('account.plex.delete', [], {id: self.id}).then(function(success) { 84 | if(!success) { 85 | return $q.reject(); 86 | } 87 | }); 88 | }; 89 | 90 | PlexAuthentication.prototype.update = function(data) { 91 | this.changed = false; 92 | this.original = angular.copy(data); 93 | 94 | // Account details 95 | this.id = data.id; 96 | this.username = data.username; 97 | 98 | this.title = data.title; 99 | this.thumb_url = data.thumb_url; 100 | 101 | // Authorization details 102 | this.authorization = data.authorization; 103 | 104 | // State 105 | this.messages = []; 106 | this.state = ''; 107 | }; 108 | 109 | PlexAuthentication.prototype.updateAuthorization = function(token_plex, user) { 110 | // Update account details 111 | this.username = user.username; 112 | 113 | this.title = user._title; 114 | this.thumb_url = user._thumb; 115 | 116 | // Update token 117 | this.authorization.basic.token_plex = token_plex; 118 | 119 | // Set `changed` flag 120 | this.changed = true; 121 | }; 122 | 123 | PlexAuthentication.prototype.onSaveError = function(error) { 124 | if(!Utils.isDefined(error) || !Utils.isDefined(error.message)) { 125 | return; 126 | } 127 | 128 | // Store error message 129 | this.appendMessage('error', error.message); 130 | }; 131 | 132 | return PlexAuthentication; 133 | }); 134 | -------------------------------------------------------------------------------- /app/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Page Not Found :( 6 | 136 | 137 | 138 |
139 |

Not found :(

140 |

Sorry, but the page you were trying to view does not exist.

141 |

It looks like this was the result of either:

142 |
    143 |
  • a mistyped address
  • 144 |
  • an out-of-date link
  • 145 |
146 | 149 | 150 |
151 | 152 | 153 | -------------------------------------------------------------------------------- /app/scripts/controllers/configuration/rules.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * @ngdoc function 5 | * @name configurationApp.controller:RulesController 6 | * @description 7 | * # RulesController 8 | * Controller of the configurationApp 9 | */ 10 | angular.module('configurationApp') 11 | .controller('RulesController', function (ClientRuleCollection, UserRuleCollection, $q, $rootScope, $scope) { 12 | $scope.client = new ClientRuleCollection(); 13 | $scope.user = new UserRuleCollection(); 14 | 15 | // Setup sortable rule tables 16 | $('.rules table').sortable({ 17 | handle: 'a.move', 18 | 19 | containerSelector: 'table', 20 | itemPath: '> tbody', 21 | itemSelector: 'tr', 22 | placeholder: '', 23 | 24 | onDragStart: function($item, container, _super) { 25 | var node = $item[0], 26 | tbody = node.parentNode, 27 | index = Array.prototype.indexOf.call(tbody.children, $item[0]); 28 | 29 | $item.addClass(container.group.options.draggedClass); 30 | $("body").addClass(container.group.options.bodyClass); 31 | 32 | $item.data('drag-from', index); 33 | }, 34 | onDrop: function($item, container, _super) { 35 | var node = $item[0], 36 | tbody = node.parentNode, 37 | from = $item.data('drag-from'), 38 | to = Array.prototype.indexOf.call(tbody.children, $item[0]); 39 | 40 | // Update dragged item 41 | $item.removeClass(container.group.options.draggedClass).removeAttr("style"); 42 | 43 | // Update cursor 44 | $("body").removeClass(container.group.options.bodyClass); 45 | 46 | // Retrieve collection 47 | var $table = $item.parents('table'), 48 | collectionName = $table.data('collection'), 49 | collection = null; 50 | 51 | if(collectionName === 'client') { 52 | collection = $scope.client; 53 | } else if(collectionName === 'user') { 54 | collection = $scope.user; 55 | } else { 56 | console.warn('Unable to find collection with the name "%s"', collectionName); 57 | return; 58 | } 59 | 60 | // Retrieve rule from `collection` 61 | var item = collection.rules[from]; 62 | 63 | if(typeof item === 'undefined') { 64 | console.warn('Unable to retrieve item with index %s', from); 65 | return; 66 | } 67 | 68 | // Move rule inside `collection` 69 | collection.rules.splice(from, 1); 70 | collection.rules.splice(to, 0, item); 71 | 72 | // Update rule priorities 73 | collection.updatePriorities(); 74 | 75 | $scope.$apply(); 76 | } 77 | }); 78 | 79 | $scope.refresh = function() { 80 | var promises = [ 81 | // Retrieve accounts 82 | $rootScope.$s.call('account.list').then(function(accounts) { 83 | // Filter accounts (remove "server" account) 84 | accounts = _.filter(accounts, function (account) { 85 | return account.id > 0 && !account.deleted; 86 | }); 87 | 88 | // Map accounts 89 | accounts = _.map(accounts, function(account) { 90 | return { 91 | $order: 10, 92 | type: 'name', 93 | 94 | value: account.id, 95 | text: account.name 96 | } 97 | }); 98 | 99 | // Update collections 100 | $scope.client.updateAccounts(accounts); 101 | $scope.user.updateAccounts(accounts); 102 | }, function() { 103 | return $q.reject('Unable to retrieve accounts'); 104 | }), 105 | 106 | // Refresh rule collections 107 | $scope.client.refresh(), 108 | $scope.user.refresh() 109 | ]; 110 | 111 | return $q.all(promises); 112 | }; 113 | 114 | $scope.attributeLabel = function(value) { 115 | if(value === null || value === '*') { 116 | return 'Any'; 117 | } 118 | 119 | if(value === '@') { 120 | return 'Map'; 121 | } 122 | 123 | return value; 124 | }; 125 | 126 | // Initial rules refresh 127 | $scope.refresh(); 128 | }); 129 | -------------------------------------------------------------------------------- /app/scripts/services/authentication.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .factory('Authentication', function(RavenTags, Utils, $http, $q, $rootScope) { 5 | var identifierSalt = 'MLawOtoMiFf5Ni9nbu0bTes2+UkrVLMZ8LSPwA+qTtA=', 6 | tokenRegex = /^server\.\w+\.((token_channel)|(token_channel_expire)|(token_plex))$/, 7 | user = null; 8 | 9 | function updateAuthentication(authenticated, user) { 10 | // Update authentication scope 11 | $rootScope.$a = { 12 | authenticated: Utils.isDefined(authenticated) ? authenticated : false, 13 | user: Utils.isDefined(user) ? user : null 14 | }; 15 | 16 | // Update server scope 17 | if(!$rootScope.$a.authenticated) { 18 | $rootScope.$s = null; 19 | } 20 | 21 | // Update plex.js token 22 | if(Utils.isDefined(user)) { 23 | plex.cloud.token = user._authenticationToken; 24 | } else { 25 | plex.cloud.token = null; 26 | } 27 | } 28 | 29 | updateAuthentication(); 30 | 31 | var Authentication = { 32 | authenticated: function() { 33 | return user !== null; 34 | }, 35 | user: function(value) { 36 | if(value !== undefined) { 37 | // Update user details 38 | user = value; 39 | 40 | // Update raven context 41 | if(value !== null) { 42 | RavenTags.update({ 43 | user_identifier: typeof user.username !== 'undefined' ? 44 | md5(user.username + identifierSalt) : 45 | null 46 | }); 47 | } else { 48 | RavenTags.update({ 49 | user_identifier: null 50 | }); 51 | } 52 | 53 | return value; 54 | } 55 | 56 | return user; 57 | }, 58 | token: function(value) { 59 | if(value === undefined) { 60 | return localStorage['plex.token']; 61 | } 62 | 63 | if(value === null) { 64 | delete localStorage['plex.token']; 65 | } else { 66 | localStorage['plex.token'] = value; 67 | } 68 | 69 | return value; 70 | }, 71 | login: function(token, user) { 72 | if(Utils.isDefined(token) && Utils.isDefined(user)) { 73 | // Update with new authentication 74 | this.token(token); 75 | this.user(user); 76 | 77 | updateAuthentication(true, user); 78 | return true; 79 | } 80 | 81 | // Clear current authentication 82 | updateAuthentication(); 83 | return false; 84 | }, 85 | logout: function() { 86 | // Destroy plex authentication details 87 | this.token(null); 88 | this.user(null); 89 | 90 | // Destroy server tokens 91 | var deleteKeys = []; 92 | 93 | for(var i = 0; i < localStorage.length; ++i) { 94 | var key = localStorage.key(i); 95 | 96 | if(tokenRegex.exec(key) === null) { 97 | continue; 98 | } 99 | 100 | deleteKeys.push(key); 101 | } 102 | 103 | _.each(deleteKeys, function(key) { 104 | delete localStorage[key]; 105 | }); 106 | 107 | updateAuthentication(); 108 | }, 109 | get: function() { 110 | var deferred = $q.defer(), 111 | self = this, 112 | token = self.token(), 113 | user = self.user(); 114 | 115 | if(typeof token === 'undefined' || token === null) { 116 | deferred.reject(); 117 | return deferred.promise; 118 | } 119 | 120 | if(user !== null) { 121 | deferred.resolve(user); 122 | return deferred.promise; 123 | } 124 | 125 | // Send request 126 | plex.cloud['/users'].account(token).then(function(response) { 127 | var data = response.data; 128 | 129 | self.user(data.user); 130 | 131 | updateAuthentication(true, data.user); 132 | 133 | deferred.resolve(data.user); 134 | }, function() { 135 | updateAuthentication(); 136 | 137 | deferred.reject(); 138 | }); 139 | 140 | return deferred.promise; 141 | } 142 | }; 143 | 144 | return Authentication; 145 | }); 146 | -------------------------------------------------------------------------------- /app/scripts/controllers/configuration/accounts.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * @ngdoc function 5 | * @name configurationApp.controller:AccountsController 6 | * @description 7 | * # AccountsController 8 | * Controller of the configurationApp 9 | */ 10 | angular.module('configurationApp') 11 | .controller('AccountsController', function (Account, Utils, $location, $modal, $q, $rootScope, $routeParams, $scope) { 12 | $scope.accounts = {}; 13 | $scope.account = null; 14 | 15 | function selectAccount(id) { 16 | id = parseInt(id); 17 | 18 | if(id === null || isNaN(id)) { 19 | $scope.account = null; 20 | return $q.reject(); 21 | } 22 | 23 | if(Utils.isDefined($scope.accounts[id])) { 24 | // Set current account 25 | $scope.account = $scope.accounts[id]; 26 | } else if(Object.keys($scope.accounts).length > 0) { 27 | // Missing account, select the first account 28 | return selectFirstAccount(); 29 | } else { 30 | return $q.reject(); 31 | } 32 | 33 | // Initial account preferences refresh 34 | return $scope.accountRefresh(); 35 | } 36 | 37 | function selectFirstAccount() { 38 | // Update selected account parameter 39 | $location.search('id', Object.keys($scope.accounts)[0]); 40 | 41 | return $q.resolve(); 42 | } 43 | 44 | function select() { 45 | if(typeof $routeParams.id !== 'undefined') { 46 | // Load account from parameter 47 | return selectAccount($routeParams.id); 48 | } 49 | 50 | // Load first account 51 | return selectFirstAccount(); 52 | } 53 | 54 | $scope.accountRefresh = function() { 55 | if(typeof $scope.account === 'undefined' || $scope.account === null) { 56 | // Currently selected account has been removed 57 | return $q.reject(); 58 | } 59 | 60 | return $scope.account.refresh($rootScope.$s); 61 | }; 62 | 63 | $scope.accountDiscard = function() { 64 | return $scope.account.discard(); 65 | }; 66 | 67 | $scope.accountSave = function() { 68 | return $scope.account.save($rootScope.$s); 69 | }; 70 | 71 | $scope.accountDelete = function() { 72 | var modal = $modal.open({ 73 | templateUrl: 'modals/deleteAccount.html', 74 | windowClass: 'small', 75 | scope: $scope 76 | }); 77 | 78 | return modal.result.then(function() { 79 | return $scope.account.delete($rootScope.$s).then(function () { 80 | // Refresh accounts 81 | return $scope.refresh(); 82 | }); 83 | }); 84 | }; 85 | 86 | $scope.create = function(name) { 87 | if(name === null || name === '') { 88 | return $q.reject('Name is required'); 89 | } 90 | 91 | return $rootScope.$s.call('account.create', [], {name: name}).then(function() { 92 | // Refresh accounts 93 | return $scope.refresh(); 94 | }, function(error) { 95 | if(error === null || typeof error.message !== 'string') { 96 | return $q.reject('Unknown error'); 97 | } 98 | 99 | return $q.reject(error.message); 100 | }); 101 | }; 102 | 103 | $scope.refresh = function() { 104 | // Retrieve accounts from server 105 | return $rootScope.$s.call('account.list', [], {full: true}).then(function (accounts) { 106 | // Parse accounts 107 | $scope.accounts = _.indexBy(_.map(_.filter(accounts, function (account) { 108 | return account.id > 0 && !account.deleted; 109 | }), function (account) { 110 | return new Account(account); 111 | }), function (account) { 112 | return account.id; 113 | }); 114 | 115 | // Trigger initial account load 116 | return select(); 117 | }, function() { 118 | return $q.reject(); 119 | }); 120 | }; 121 | 122 | // Initial account refresh 123 | $scope.refresh(); 124 | 125 | // Refresh when authentication accounts are deleted 126 | $scope.$on('account.plex.deleted', function() { $scope.refresh(); }); 127 | $scope.$on('account.trakt.deleted', function() { $scope.refresh(); }); 128 | 129 | // Watch for selected account change 130 | $scope.$on('$routeUpdate', function() { 131 | select(); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /app/scripts/models/account.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .factory('Account', function(AccountAuthentication, Differ, Options, Utils, $q) { 5 | var defaults = { 6 | plex: { 7 | authorization: { 8 | basic: { valid: false } 9 | } 10 | }, 11 | trakt: { 12 | authorization: { 13 | basic: { valid: false }, 14 | oauth: { valid: false } 15 | } 16 | } 17 | }; 18 | 19 | function Account(data) { 20 | this.authentication = new AccountAuthentication(); 21 | 22 | this.original = null; 23 | this.options = null; 24 | 25 | this.id = null; 26 | this.name = null; 27 | 28 | this.thumb_url = null; 29 | 30 | // Update with initial data 31 | this.update(data); 32 | } 33 | 34 | Account.prototype.current = function() { 35 | var self = this, 36 | promises = [ 37 | this.authentication.plex.current(), 38 | this.authentication.trakt.current() 39 | ]; 40 | 41 | return $q.all(promises).then(function(results) { 42 | var current = { 43 | name: self.name 44 | }; 45 | 46 | _.each(results, function(result) { 47 | $.extend(true, current, result); 48 | }); 49 | 50 | return current; 51 | }, function(error) { 52 | return $q.reject(error); 53 | }); 54 | }; 55 | 56 | Account.prototype.update = function(data) { 57 | // Set defaults 58 | data = $.extend(true, angular.copy(defaults), data); 59 | 60 | // Copy original values for the differ 61 | this.original = angular.copy(data); 62 | 63 | // Update attributes 64 | this.id = data.id; 65 | this.name = data.name; 66 | 67 | this.thumb_url = data.thumb_url; 68 | 69 | this.authentication.update(data); 70 | }; 71 | 72 | Account.prototype.refresh = function(server) { 73 | var self = this; 74 | 75 | // Retrieve account options 76 | return $q.all([ 77 | server.call('account.get', [], {full: true, id: self.id}).then(function(data) { 78 | self.update(data); 79 | }), 80 | server.call('option.list', [], {account: self.id}).then(function(options) { 81 | self.options = new Options(options, self); 82 | }) 83 | ]); 84 | }; 85 | 86 | Account.prototype.delete = function(server) { 87 | var self = this; 88 | 89 | return server.call('account.delete', [], {id: self.id}).then(function(success) { 90 | if(!success) { 91 | return $q.reject(); 92 | } 93 | }); 94 | }; 95 | 96 | Account.prototype.discard = function() { 97 | // Discard account authentication/details 98 | this.update(this.original); 99 | 100 | // Discard account options 101 | return this.options.discard(); 102 | }; 103 | 104 | Account.prototype.save = function(server) { 105 | var self = this; 106 | 107 | // Clear authentication errors 108 | this.authentication.clear(); 109 | 110 | // Save account changes 111 | return $q.all([ 112 | // Update account details 113 | this.current().then(function(data) { 114 | // Send changes to server 115 | return server.call('account.update', [], {id: self.id, data: data}) 116 | .then(function(account) { 117 | // Account updated successfully, update local data 118 | self.update(account); 119 | }); 120 | }), 121 | 122 | // Update account options 123 | this.options.save(server) 124 | ]).catch(function(error) { 125 | // Unable to save changes to server 126 | self.handleError(error); 127 | return $q.reject(error); 128 | }); 129 | }; 130 | 131 | Account.prototype.handleError = function(error) { 132 | if(!Utils.isDefined(error) || !Utils.isDefined(error.code) || !Utils.isDefined(error.message)) { 133 | return; 134 | } 135 | 136 | // Process error 137 | if(error.code.indexOf("error.account.trakt.") === 0) { 138 | this.authentication.trakt.onSaveError(error); 139 | } else if(error.code.indexOf("error.account.plex.") === 0) { 140 | this.authentication.plex.onSaveError(error); 141 | } else { 142 | this.authentication.onSaveError(error); 143 | } 144 | 145 | // Check current authentication state 146 | this.authentication.check(); 147 | }; 148 | 149 | return Account; 150 | }); 151 | -------------------------------------------------------------------------------- /app/scripts/models/configuration/rules/user/collection.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .factory('UserRuleCollection', function(UserRule, $q, $rootScope, $timeout) { 5 | var accountFunctions = [ 6 | { $order: 1, value: '-', text: 'None' }, 7 | { $order: 2, value: '@', text: 'Map' } 8 | ], 9 | attributeFunctions = [ 10 | { $order: 1, value: '*', text: 'Any' } 11 | ]; 12 | 13 | function UserRuleCollection() { 14 | this.available = { 15 | names: null 16 | }; 17 | 18 | this.accounts = null; 19 | this.accountsById = null; 20 | 21 | this.original = null; 22 | this.rules = null; 23 | } 24 | 25 | UserRuleCollection.prototype.create = function() { 26 | // Reset rule states to "view" 27 | this.reset(); 28 | 29 | // Create new rule 30 | var rule = new UserRule( 31 | this, 32 | { 33 | name: '*', 34 | 35 | account: { 36 | id: '-', 37 | name: 'None' 38 | }, 39 | priority: this.rules.length + 1 40 | }, 41 | 'edit' 42 | ); 43 | 44 | this.rules.push(rule); 45 | 46 | // Focus new rule 47 | $timeout(function() { 48 | rule.focus(); 49 | }) 50 | }; 51 | 52 | UserRuleCollection.prototype.delete = function(rule) { 53 | var index = this.rules.indexOf(rule); 54 | 55 | if(index === -1) { 56 | return; 57 | } 58 | 59 | // Delete rule from collection 60 | this.rules.splice(index, 1); 61 | 62 | // Update rule priorities 63 | this.updatePriorities(); 64 | }; 65 | 66 | UserRuleCollection.prototype.discard = function() { 67 | // Discard rule changes 68 | this.updateRules(this.original); 69 | 70 | // Update rule priorities 71 | this.updatePriorities(); 72 | 73 | return $q.resolve(); 74 | }; 75 | 76 | UserRuleCollection.prototype.refresh = function() { 77 | var self = this; 78 | 79 | return $q.all([ 80 | // Retrieve seen users 81 | $rootScope.$s.call('session.user.list').then( 82 | $.proxy(self.updateUsers, self), 83 | function() { 84 | return $q.reject('Unable to retrieve users'); 85 | } 86 | ), 87 | 88 | // Retrieve user rules 89 | $rootScope.$s.call('session.user.rule.list', [], {full: true}).then( 90 | $.proxy(self.updateRules, self), 91 | function() { 92 | return $q.reject('Unable to retrieve user rules'); 93 | } 94 | ) 95 | ]); 96 | }; 97 | 98 | UserRuleCollection.prototype.reset = function() { 99 | // Reset rules back to view mode 100 | _.each(this.rules, function(rule) { 101 | rule.save(); 102 | }); 103 | }; 104 | 105 | UserRuleCollection.prototype.save = function() { 106 | var self = this, 107 | current = _.map(this.rules, function(rule) { 108 | return rule.current(); 109 | }); 110 | 111 | return $rootScope.$s.call('session.user.rule.update', [], {current: current, full: true}).then( 112 | $.proxy(self.updateRules, self), 113 | function() { 114 | return $q.reject('Unable to update user rules'); 115 | } 116 | ); 117 | }; 118 | 119 | UserRuleCollection.prototype.updateAccounts = function(accounts) { 120 | this.accounts = [].concat(accountFunctions, accounts); 121 | 122 | this.accountsById = _.indexBy(this.accounts, 'value'); 123 | }; 124 | 125 | UserRuleCollection.prototype.updatePriorities = function() { 126 | // Update rule priorities 127 | for(var i = 0; i < this.rules.length; ++i) { 128 | this.rules[i].priority = i + 1; 129 | } 130 | }; 131 | 132 | UserRuleCollection.prototype.updateRules = function(rules) { 133 | var self = this; 134 | 135 | // Store original rules 136 | this.original = angular.copy(rules); 137 | 138 | // Parse rules 139 | this.rules = _.map(rules, function(rule) { 140 | return new UserRule(self, rule); 141 | }); 142 | }; 143 | 144 | UserRuleCollection.prototype.updateUsers = function(users) { 145 | this.available.names = [].concat(attributeFunctions, _.map(users, function (user) { 146 | return { 147 | $order: 10, 148 | type: 'name', 149 | 150 | value: user.name, 151 | text: user.name 152 | }; 153 | })); 154 | }; 155 | 156 | return UserRuleCollection; 157 | }); 158 | -------------------------------------------------------------------------------- /test/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // http://karma-runner.github.io/0.12/config/configuration-file.html 3 | // Generated on 2015-07-01 using 4 | // generator-karma 1.0.0 5 | 6 | module.exports = function(config) { 7 | 'use strict'; 8 | 9 | config.set({ 10 | // enable / disable watching file and executing tests whenever any file changes 11 | autoWatch: true, 12 | 13 | // base path, that will be used to resolve files and exclude 14 | basePath: '../', 15 | 16 | // testing framework to use (jasmine/mocha/qunit/...) 17 | // as well as any additional frameworks (requirejs/chai/sinon/...) 18 | frameworks: [ 19 | "jasmine" 20 | ], 21 | 22 | // list of files / patterns to load in the browser 23 | files: [ 24 | // bower:js 25 | 'bower_components/modernizr/modernizr.js', 26 | 'bower_components/jquery/dist/jquery.js', 27 | 'bower_components/angular/angular.js', 28 | 'bower_components/angular-animate/angular-animate.js', 29 | 'bower_components/angular-aria/angular-aria.js', 30 | 'bower_components/angular-cookies/angular-cookies.js', 31 | 'bower_components/angular-messages/angular-messages.js', 32 | 'bower_components/angular-resource/angular-resource.js', 33 | 'bower_components/angular-route/angular-route.js', 34 | 'bower_components/angular-sanitize/angular-sanitize.js', 35 | 'bower_components/spin.js/spin.js', 36 | 'bower_components/angular-spinner/angular-spinner.js', 37 | 'bower_components/angular-touch/angular-touch.js', 38 | 'bower_components/fastclick/lib/fastclick.js', 39 | 'bower_components/jquery.cookie/jquery.cookie.js', 40 | 'bower_components/jquery-placeholder/jquery.placeholder.js', 41 | 'bower_components/foundation/js/foundation.js', 42 | 'bower_components/angular-foundation/mm-foundation-tpls.js', 43 | 'bower_components/ua-parser-js/src/ua-parser.js', 44 | 'bower_components/x2js/xml2json.min.js', 45 | 'bower_components/angular-xml/angular-xml.js', 46 | 'bower_components/underscore/underscore.js', 47 | 'bower_components/cerealizer.js/dist/cerealizer.js', 48 | 'bower_components/ladda-foundation-5/dist/ladda.js', 49 | 'bower_components/trakt.js/dist/trakt.js', 50 | 'bower_components/httpinvoke/httpinvoke-browser.js', 51 | 'bower_components/plex.js/dist/global/when.js', 52 | 'bower_components/plex.js/dist/global/plex.js', 53 | 'bower_components/sifter/sifter.js', 54 | 'bower_components/microplugin/src/microplugin.js', 55 | 'bower_components/selectize/dist/js/selectize.js', 56 | 'bower_components/angular-selectize2/dist/selectize.js', 57 | 'bower_components/jquery-sortable/source/js/jquery-sortable.js', 58 | 'bower_components/raven-js/dist/raven.js', 59 | 'bower_components/angular-raven/angular-raven.js', 60 | 'bower_components/JavaScript-MD5/js/md5.js', 61 | 'bower_components/waypoints/lib/noframework.waypoints.min.js', 62 | 'bower_components/SHA-1/sha1.js', 63 | 'bower_components/marked/lib/marked.js', 64 | 'bower_components/angular-marked/dist/angular-marked.js', 65 | 'bower_components/angular-mocks/angular-mocks.js', 66 | // endbower 67 | 68 | 'bower_components/angulartics/src/angulartics.js', 69 | 'bower_components/angulartics/src/angulartics-ga.js', 70 | 71 | "app/scripts/**/*.js", 72 | "test/mock/**/*.js", 73 | "test/spec/**/*.js" 74 | ], 75 | 76 | // list of files / patterns to exclude 77 | exclude: [ 78 | ], 79 | 80 | // web server port 81 | port: 8080, 82 | 83 | // Start these browsers, currently available: 84 | // - Chrome 85 | // - ChromeCanary 86 | // - Firefox 87 | // - Opera 88 | // - Safari (only Mac) 89 | // - PhantomJS 90 | // - IE (only Windows) 91 | browsers: [ 92 | "PhantomJS" 93 | ], 94 | 95 | // Which plugins to enable 96 | plugins: [ 97 | "karma-phantomjs-launcher", 98 | "karma-jasmine" 99 | ], 100 | 101 | // Continuous Integration mode 102 | // if true, it capture browsers, run tests and exit 103 | singleRun: false, 104 | 105 | colors: true, 106 | 107 | // level of logging 108 | // possible values: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG 109 | logLevel: config.LOG_INFO, 110 | 111 | // Uncomment the following lines if you are using grunt's server to run the tests 112 | // proxies: { 113 | // '/': 'http://localhost:9000/' 114 | // }, 115 | // URL root prevent conflicts with the site root 116 | // urlRoot: '_karma_' 117 | }); 118 | }; 119 | -------------------------------------------------------------------------------- /app/scripts/directives/plex/home.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .directive('coPlexHome', function() { 5 | function toNumber(keyCode) { 6 | if(keyCode >= 48 && keyCode <= 57) { 7 | return String.fromCharCode(keyCode); 8 | } 9 | 10 | if(keyCode >= 96 && keyCode <= 105) { 11 | return String.fromCharCode(keyCode - 48); 12 | } 13 | 14 | return null; 15 | } 16 | 17 | function PlexHome($scope) { 18 | this.$scope = $scope; 19 | 20 | // Bind scope functions 21 | var self = this; 22 | 23 | $scope.$on('reset', function() { 24 | self.reset(); 25 | }); 26 | 27 | $scope.pinKeyDown = function($event) { 28 | self.pinKeyDown($event); 29 | }; 30 | 31 | $scope.select = function(user) { 32 | self.select(user); 33 | }; 34 | 35 | $scope.switch = function(state) { 36 | $scope.state = state; 37 | }; 38 | } 39 | 40 | PlexHome.prototype.reset = function() { 41 | var $scope = this.$scope; 42 | 43 | // Reset scope values 44 | $scope.current = null; 45 | $scope.state = 'list'; 46 | 47 | $scope.users = []; 48 | }; 49 | 50 | PlexHome.prototype.refresh = function() { 51 | var $scope = this.$scope; 52 | 53 | plex.cloud['/api/home'].users().then(function(response) { 54 | var data = response.data, 55 | users = data.MediaContainer.User; 56 | 57 | if(typeof users.length === 'undefined') { 58 | users = [users]; 59 | } 60 | 61 | $scope.$apply(function() { 62 | $scope.users = users; 63 | }); 64 | 65 | console.log($scope.users); 66 | }, function() { 67 | $scope.$apply(function() { 68 | $scope.users = []; 69 | }); 70 | }); 71 | }; 72 | 73 | PlexHome.prototype.select = function(user) { 74 | if(user._protected === '1') { 75 | this.pinLogin(user); 76 | } else { 77 | this.basicLogin(user); 78 | } 79 | }; 80 | 81 | PlexHome.prototype.basicLogin = function(user, pin) { 82 | var $scope = this.$scope; 83 | 84 | // Fire callback 85 | $scope.onAuthenticated(user._id, pin); 86 | }; 87 | 88 | PlexHome.prototype.pinLogin = function(user) { 89 | var $scope = this.$scope; 90 | 91 | // Switch to PIN input state 92 | $scope.current = user; 93 | $scope.state = 'pin'; 94 | }; 95 | 96 | PlexHome.prototype.pinKeyDown = function($event) { 97 | var $scope = this.$scope, 98 | $input = $($event.target), 99 | $cell = $input.parent('span'), 100 | $container = $cell.parent('div'); 101 | 102 | // Prevent default character insertion 103 | $event.preventDefault(); 104 | 105 | // Handle key 106 | if($event.keyCode === 8) { 107 | if($input.val() !== '') { 108 | // Clear current field 109 | $input.val(''); 110 | return; 111 | } 112 | 113 | // Find previous field 114 | var $prev = $cell.prev(); 115 | 116 | if($prev.length === 0) { 117 | return; 118 | } 119 | 120 | // Clear previous field 121 | $('input', $prev) 122 | .val('') 123 | .focus(); 124 | } else { 125 | // Set character 126 | var char = toNumber($event.keyCode); 127 | 128 | if(char === null) { 129 | return; 130 | } 131 | 132 | $input.val(char); 133 | 134 | // Move to next field 135 | var $next = $cell.next(); 136 | 137 | if($next.length === 0) { 138 | // Done 139 | var $fields = $('input', $container), 140 | pin = ''; 141 | 142 | // Build pin from fields 143 | for(var i = 0; i < $fields.length; ++i) { 144 | var val = $($fields[i]).val(); 145 | 146 | if(val === '') { 147 | return; 148 | } 149 | 150 | pin += val; 151 | } 152 | 153 | // Fire callback 154 | $scope.onAuthenticated($scope.current._id, pin); 155 | return; 156 | } 157 | 158 | $('input', $next) 159 | .focus(); 160 | } 161 | }; 162 | 163 | return { 164 | restrict: 'E', 165 | scope: { 166 | onAuthenticated: '=coAuthenticated' 167 | }, 168 | templateUrl: 'directives/plex/home.html', 169 | 170 | controller: function($scope) { 171 | // Set initial scope values 172 | $scope.current = null; 173 | $scope.state = 'list'; 174 | 175 | $scope.users = []; 176 | 177 | // Construct main controller 178 | var main = new PlexHome($scope); 179 | 180 | // Initial account refresh 181 | main.refresh(); 182 | }, 183 | 184 | link: function(scope, element, attrs) { 185 | } 186 | }; 187 | }); 188 | -------------------------------------------------------------------------------- /app/styles/configuration/rules.scss: -------------------------------------------------------------------------------- 1 | body.dragging, body.dragging * { 2 | cursor: move !important; 3 | } 4 | 5 | .rules { 6 | margin-top: 25px; 7 | 8 | h5, h6 { 9 | line-height: 2.6; 10 | 11 | margin: 0; 12 | padding-left: 5px; 13 | } 14 | 15 | table { 16 | width: 100%; 17 | 18 | background: transparent; 19 | border: none; 20 | 21 | thead { 22 | background: rgba(0, 0, 0, 0.14); 23 | 24 | th { 25 | padding-left: 19px; 26 | } 27 | } 28 | 29 | tbody { 30 | tr { 31 | background: rgba(0, 0, 0, 0.035); 32 | } 33 | 34 | tr:nth-of-type(even) { 35 | background: rgba(0, 0, 0, 0.055); 36 | } 37 | } 38 | 39 | tfoot { 40 | background: transparent; 41 | } 42 | 43 | tr { 44 | height: 44px; 45 | } 46 | 47 | th.actions { 48 | width: 64px; 49 | } 50 | 51 | th.account { 52 | min-width: 145px; 53 | width: 18%; 54 | } 55 | 56 | th.name { 57 | min-width: 100px; 58 | } 59 | 60 | td { 61 | position: relative; 62 | 63 | padding: 0; 64 | 65 | input, select { 66 | margin: 0; 67 | } 68 | 69 | select, .selectize-control { 70 | height: 30px; 71 | } 72 | 73 | .selectize-control { 74 | margin-bottom: 6px; 75 | 76 | .selectize-dropdown, 77 | .selectize-input, 78 | .selectize-input input { 79 | font-size: 14px; 80 | } 81 | 82 | .selectize-dropdown { 83 | .option { 84 | overflow: hidden; 85 | text-overflow: ellipsis; 86 | white-space: nowrap; 87 | } 88 | 89 | .optgroup-header { 90 | font-weight: bold; 91 | } 92 | } 93 | 94 | input { 95 | height: 18px; 96 | } 97 | } 98 | 99 | .selectize-input .item { 100 | display: block; 101 | float: left; 102 | 103 | max-width: 88%; 104 | 105 | overflow: hidden; 106 | text-overflow: ellipsis; 107 | white-space: nowrap; 108 | } 109 | 110 | .wrapper { 111 | position: absolute; 112 | left: 10px; 113 | right: 10px; 114 | top: 0; 115 | bottom: 0; 116 | } 117 | 118 | .wrapper.edit { 119 | top: 4px; 120 | } 121 | 122 | .wrapper.plain { 123 | top: 3px; 124 | 125 | padding: 10px 9px; 126 | 127 | overflow: hidden; 128 | text-overflow: ellipsis; 129 | white-space: nowrap; 130 | } 131 | } 132 | 133 | td.actions { 134 | padding: 5px; 135 | 136 | font-size: 17px; 137 | text-align: right; 138 | 139 | // disable text selection on action buttons 140 | -moz-user-select: none; 141 | -ms-user-select: none; 142 | -webkit-user-select: none; 143 | user-select: none; 144 | 145 | a { 146 | display: inline-block; 147 | width: 14px; 148 | 149 | margin-left: 3px; 150 | 151 | color: #333; 152 | } 153 | 154 | a:hover { 155 | color: #777; 156 | } 157 | 158 | a.move { 159 | display: block; 160 | float: left; 161 | position: absolute; 162 | 163 | width: 20px; 164 | margin-left: -6px; 165 | 166 | text-align: center; 167 | 168 | color: #BBB; 169 | } 170 | 171 | a.move:hover { 172 | color: #777; 173 | cursor: -webkit-grab; 174 | } 175 | } 176 | 177 | td.account .wrapper span.deleted { 178 | color: #AAA; 179 | 180 | font-style: italic; 181 | } 182 | 183 | tfoot td.actions { 184 | a { 185 | color: #999; 186 | } 187 | 188 | a:hover { 189 | color: #777; 190 | } 191 | } 192 | 193 | .dragged { 194 | position: absolute; 195 | opacity: 0.5; 196 | z-index: 2000; 197 | } 198 | 199 | tr.placeholder { 200 | position: relative; 201 | 202 | height: 0; 203 | } 204 | 205 | tr.placeholder:before { 206 | position: absolute; 207 | width: 0; 208 | height: 0; 209 | 210 | margin-top: -5px; 211 | left: 5px; 212 | 213 | content: ""; 214 | 215 | border: 5px solid transparent; 216 | border-left-color: #333; 217 | border-right: none; 218 | } 219 | } 220 | 221 | .clients { 222 | table { 223 | th.machine-identifier { 224 | min-width: 100px; 225 | width: 24%; 226 | } 227 | 228 | th.name { 229 | min-width: 100px; 230 | } 231 | 232 | th.address { 233 | min-width: 100px; 234 | } 235 | 236 | td.machine-identifier { 237 | .selectize-dropdown { 238 | .option div { 239 | overflow: hidden; 240 | text-overflow: ellipsis; 241 | white-space: nowrap; 242 | } 243 | 244 | .option small { 245 | font-size: 10px; 246 | color: rgba(0, 0, 0, 0.65); 247 | } 248 | } 249 | } 250 | } 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /app/scripts/directives/trakt/login.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .directive('coTraktLogin', function(Utils, $http, $q) { 5 | var tr = new trakt.Client( 6 | 'c9ccd3684988a7862a8542ae0000535e0fbd2d1c0ca35583af7ea4e784650a61', 7 | 'bf00575b1ad252b514f14b2c6171fe650d474091daad5eb6fa890ef24d581f65' 8 | ); 9 | 10 | function TraktLogin($scope) { 11 | this.$scope = $scope; 12 | 13 | // Bind scope functions 14 | var self = this; 15 | 16 | $scope.$on('reset', function() { 17 | self.reset(); 18 | }); 19 | 20 | $scope.basicLogin = function() { 21 | return self.basicLogin(); 22 | }; 23 | 24 | $scope.pinLogin = function() { 25 | return self.pinLogin(); 26 | }; 27 | 28 | $scope.switch = function(method) { 29 | $scope.messages = []; 30 | $scope.method = method; 31 | }; 32 | } 33 | 34 | TraktLogin.prototype.appendMessage = function(type, content) { 35 | var $scope = this.$scope; 36 | 37 | $scope.messages.push({ 38 | type: type, 39 | content: content 40 | }); 41 | }; 42 | 43 | TraktLogin.prototype.reset = function() { 44 | var $scope = this.$scope; 45 | 46 | // Reset state 47 | $scope.messages = []; 48 | $scope.method = 'pin'; 49 | }; 50 | 51 | TraktLogin.prototype.basicLogin = function() { 52 | var $scope = this.$scope; 53 | 54 | // Reset messages 55 | $scope.messages = []; 56 | 57 | // Fire callback 58 | $scope.basicAuthenticated({ 59 | credentials: $scope.basic 60 | }); 61 | 62 | return $q.resolve(); 63 | }; 64 | 65 | TraktLogin.prototype.pinLogin = function() { 66 | var $scope = this.$scope, 67 | self = this; 68 | 69 | // Reset messages 70 | $scope.messages = []; 71 | 72 | // Request token for pin code 73 | return tr.oauth.token($scope.pin.code).then(function(authorization) { 74 | // Request account details 75 | return tr['users/settings'].get(authorization.access_token).then(function(settings) { 76 | $scope.$apply(function() { 77 | // Fire callback 78 | $scope.pinAuthenticated({ 79 | authorization: authorization, 80 | credentials: $scope.pin, 81 | settings: settings 82 | }); 83 | }); 84 | }, function(data, status) { 85 | $scope.$apply(function() { 86 | self.handleError(data, status, 'Unable to retrieve account details'); 87 | }); 88 | 89 | return $q.reject(data, status); 90 | }); 91 | }, function(data, status) { 92 | $scope.$apply(function() { 93 | self.handleError(data, status, 'Unable to retrieve token'); 94 | }); 95 | 96 | return $q.reject(data, status); 97 | }); 98 | }; 99 | 100 | TraktLogin.prototype.handleError = function(data, status, fallback) { 101 | var content = this.getError(data, status, fallback); 102 | 103 | // Update messages 104 | this.appendMessage('error', content); 105 | }; 106 | 107 | TraktLogin.prototype.getError = function(data, status, fallback) { 108 | if(Utils.isDefined(data)) { 109 | // Retrieve error message from `data` 110 | if(Utils.isDefined(data.error) && data.error === 'invalid_grant') { 111 | return 'Invalid authentication pin provided'; 112 | } 113 | 114 | if(Utils.isDefined(data.error_description)) { 115 | return data.error_description; 116 | } 117 | 118 | if(Utils.isDefined(data.error)) { 119 | return data.error; 120 | } 121 | } 122 | 123 | if(Utils.isDefined(status)) { 124 | return 'HTTP Error: ' + status; 125 | } 126 | 127 | // Fallback to generic message 128 | return fallback; 129 | }; 130 | 131 | return { 132 | restrict: 'E', 133 | scope: { 134 | buttonSize: '@coButtonSize', 135 | 136 | isCancelEnabled: '=coCancelEnabled', 137 | cancelled: '=coCancelled', 138 | 139 | basic: '=coBasic', 140 | basicAuthenticated: '&coBasicAuthenticated', 141 | 142 | pin: '=coPin', 143 | pinAuthenticated: '&coPinAuthenticated' 144 | }, 145 | templateUrl: 'directives/trakt/login.html', 146 | 147 | controller: function($scope) { 148 | // Set parameter defaults 149 | if(typeof $scope.buttonSize === 'undefined') { 150 | $scope.buttonSize = 'small'; 151 | } 152 | 153 | if(typeof $scope.basic === 'undefined') { 154 | $scope.basic = { 155 | username: null, 156 | password: null 157 | }; 158 | } 159 | 160 | if(typeof $scope.pin === 'undefined') { 161 | $scope.pin = { 162 | code: null 163 | }; 164 | } 165 | 166 | // Set initial scope values 167 | $scope.messages = []; 168 | $scope.method = 'pin'; 169 | 170 | // Construct main controller 171 | var main = new TraktLogin($scope); 172 | } 173 | }; 174 | }); 175 | -------------------------------------------------------------------------------- /app/scripts/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * @ngdoc overview 5 | * @name configurationApp 6 | * @description 7 | * # configurationApp 8 | * 9 | * Main module of the application. 10 | */ 11 | angular 12 | .module('configurationApp', [ 13 | 'ngAnimate', 14 | 'ngAria', 15 | 'ngCookies', 16 | 'ngMessages', 17 | 'ngRaven', 18 | 'ngResource', 19 | 'ngRoute', 20 | 'ngSanitize', 21 | 'ngTouch', 22 | 23 | 'angulartics', 24 | 'angulartics.google.analytics', 25 | 26 | 'angularSpinner', 27 | 'hc.marked', 28 | 'mm.foundation', 29 | 'selectize', 30 | 'xml' 31 | ]) 32 | .config(function ($httpProvider, markedProvider, $ravenProvider, $routeProvider) { 33 | // Setup angular-raven 34 | // $ravenProvider.development(true); 35 | 36 | // Setup angular-xml 37 | $httpProvider.interceptors.push('xmlHttpInterceptor'); 38 | 39 | // Setup angular-marked 40 | markedProvider.setRenderer({ 41 | link: function(href, title, text) { 42 | return "" + text + ""; 43 | } 44 | }); 45 | 46 | // Setup routes 47 | $routeProvider 48 | .when('/', { 49 | templateUrl: 'views/home.html', 50 | controller: 'HomeController' 51 | }) 52 | .when('/configuration/server', { 53 | templateUrl: 'views/configuration/server.html', 54 | controller: 'ServerController' 55 | }) 56 | .when('/configuration/accounts', { 57 | templateUrl: 'views/configuration/accounts.html', 58 | controller: 'AccountsController', 59 | reloadOnSearch: false 60 | }) 61 | .when('/configuration/rules', { 62 | templateUrl: 'views/configuration/rules.html', 63 | controller: 'RulesController' 64 | }) 65 | .when('/connect', { 66 | templateUrl: 'views/connect.html', 67 | controller: 'ConnectController' 68 | }) 69 | .when('/login', { 70 | templateUrl: 'views/login.html', 71 | controller: 'LoginController' 72 | }) 73 | .when('/logout', { 74 | templateUrl: 'views/logout.html', 75 | controller: 'LogoutController' 76 | }) 77 | .otherwise({ 78 | redirectTo: '/' 79 | }); 80 | }) 81 | .run(function(Authentication, RavenTags, $location, $rootScope) { 82 | var $m = window.tfpc.metadata; 83 | 84 | // Store metadata in root scope 85 | $rootScope.$m = $m; 86 | 87 | // Send version/revision with error reports 88 | RavenTags.update({ 89 | application_version: $m.version, 90 | application_revision: $m.revision.label 91 | }); 92 | 93 | // Setup plex.js 94 | plex.cloud.http.headers.setProduct('trakt (for Plex) - Configuration', '1.0.0'); 95 | plex.cloud.http.xmlParser = 'x2js'; 96 | 97 | if(typeof localStorage['plex.client.identifier'] === 'undefined' || 98 | localStorage['plex.client.identifier'] === null) { 99 | localStorage['plex.client.identifier'] = plex.utils.random.string(); 100 | } 101 | 102 | plex.cloud.client_identifier = localStorage['plex.client.identifier']; 103 | 104 | // Redirect handler 105 | $rootScope.$r = { 106 | path: null, 107 | search: null, 108 | 109 | redirect: function() { 110 | var path, search; 111 | 112 | // Get path 113 | if(typeof this.path !== 'undefined' && this.path !== null) { 114 | // Original 115 | path = this.path; 116 | } else { 117 | // Home 118 | path = '/'; 119 | } 120 | 121 | // Get search query 122 | if(typeof this.search !== 'undefined' && this.search !== null) { 123 | // Original 124 | search = this.search; 125 | } else { 126 | // Home 127 | search = ''; 128 | } 129 | 130 | // Ensure destination is valid 131 | if(this.path === $location.path()) { 132 | path = '/'; 133 | search = ''; 134 | } 135 | 136 | // Redirect 137 | console.log('redirecting to %s (%s)', path, search); 138 | 139 | $location.path(path); 140 | $location.search(search); 141 | } 142 | }; 143 | 144 | $rootScope.$on('$routeChangeStart', function(event, next) { 145 | var controller = next.controller; 146 | 147 | if(controller === 'LoginController') { 148 | return; 149 | } 150 | 151 | if(!Authentication.authenticated()) { 152 | next.resolve = angular.extend(next.resolve || {}, { 153 | __authenticate__: function() { 154 | return Authentication.get().then(function() { 155 | console.log('authenticated'); 156 | }, function() { 157 | $rootScope.$r.path = $location.path(); 158 | $rootScope.$r.search = $location.search(); 159 | 160 | $location.path('/login'); 161 | $location.search(''); 162 | }); 163 | } 164 | }); 165 | } 166 | 167 | if(controller === 'ConnectController' || controller === 'LogoutController') { 168 | return; 169 | } 170 | 171 | if($rootScope.$s === null || typeof $rootScope.$s === 'undefined') { 172 | $rootScope.$r.path = $location.path(); 173 | $rootScope.$r.search = $location.search(); 174 | 175 | $location.path('/connect'); 176 | $location.search(''); 177 | } 178 | }); 179 | }); 180 | -------------------------------------------------------------------------------- /app/styles/configuration/accounts.scss: -------------------------------------------------------------------------------- 1 | .accounts { 2 | margin-top: 25px; 3 | 4 | .list { 5 | margin-top: 67px; 6 | 7 | .avatar { 8 | height: 32px; 9 | 10 | border-radius: 50%; 11 | 12 | margin-left: -6px; 13 | margin-right: 10px; 14 | } 15 | 16 | .state { 17 | float: right; 18 | 19 | font-size: 16px; 20 | 21 | margin-top: 4px; 22 | } 23 | 24 | .actions { 25 | display: table; 26 | width: 100%; 27 | 28 | margin-top: 10px; 29 | 30 | .actions-left { 31 | display: table-cell; 32 | 33 | width: 53px; 34 | 35 | vertical-align: top; 36 | 37 | button { 38 | margin: 0; 39 | } 40 | } 41 | 42 | .actions-right { 43 | display: table-cell; 44 | 45 | vertical-align: top; 46 | 47 | co-button-input { 48 | margin-left: 4px; 49 | margin-right: -1px; 50 | } 51 | } 52 | 53 | button { 54 | font-size: 17px; 55 | padding: 0.375rem 1.25rem; 56 | } 57 | 58 | .create { 59 | display: inline-block; 60 | } 61 | } 62 | } 63 | 64 | .side-nav { 65 | li a:hover, 66 | li a:focus { 67 | background: rgba(0, 0, 0, 0.04); 68 | } 69 | 70 | li.active a { 71 | background: rgba(0, 0, 0, 0.08); 72 | } 73 | 74 | li.active > a:first-child:not(.button), 75 | li a:first-child:not(.button):hover, 76 | li a:first-child:not(.button):focus { 77 | color: #008CBA; 78 | } 79 | } 80 | 81 | .authentication { 82 | .avatar { 83 | float: left; 84 | width: 85px; 85 | height: 85px; 86 | 87 | margin-right: 20px; 88 | 89 | border-radius: 50%; 90 | box-shadow: 0 0 9px 3px rgba(0, 0, 0, 0.15); 91 | } 92 | 93 | .account-edit { 94 | padding: 10px; 95 | } 96 | 97 | .account-view { 98 | padding: 10px; 99 | 100 | .details { 101 | position: relative; 102 | 103 | margin-left: 105px; 104 | 105 | height: 85px; 106 | 107 | a { 108 | margin: 0; 109 | } 110 | 111 | h4 { 112 | margin-top: 0; 113 | } 114 | } 115 | 116 | .actions { 117 | position: absolute; 118 | bottom: 0; 119 | right: 0; 120 | 121 | height: 32px; 122 | 123 | a { 124 | font-size: 15px; 125 | padding: 0.375rem 1.25rem; 126 | } 127 | 128 | button { 129 | font-size: 15px; 130 | padding: 0.375rem 1.25rem; 131 | } 132 | } 133 | } 134 | 135 | .tabs { 136 | dd > a { 137 | padding: 4px 12px; 138 | 139 | background-color: transparent; 140 | } 141 | 142 | dd > a:hover { 143 | background-color: rgba(0, 0, 0, 0.075); 144 | } 145 | 146 | dd.active > a { 147 | color: inherit; 148 | background-color: rgba(0, 0, 0, 0.05); 149 | } 150 | } 151 | 152 | .tabs-content { 153 | background-color: rgba(0, 0, 0, 0.05); 154 | 155 | .content { 156 | padding: 5px; 157 | } 158 | 159 | label[for="trakt.oauth.code"] a { 160 | float: right; 161 | } 162 | 163 | .accordion { 164 | dd > a { 165 | padding: 4px 7px; 166 | 167 | background: rgba(0, 0, 0, 0.06); 168 | } 169 | 170 | dd > a:hover { 171 | background: rgba(0, 0, 0, 0.09); 172 | } 173 | 174 | .content { 175 | padding: 10px; 176 | } 177 | } 178 | 179 | .messages { 180 | padding: 0 10px; 181 | } 182 | 183 | .messages small.message:first-child { 184 | margin-top: 10px !important; 185 | } 186 | 187 | .messages small.message:last-child { 188 | margin-bottom: 0 !important; 189 | } 190 | } 191 | 192 | dd .content > label + input { 193 | margin-top: 3px; 194 | } 195 | 196 | dd .content > input + label { 197 | margin-top: 9px; 198 | } 199 | 200 | dd .content > input { 201 | margin-bottom: 3px; 202 | } 203 | } 204 | 205 | // 206 | // Authentication state icon 207 | // 208 | 209 | .authentication dd > a:before, 210 | .list .state:before { 211 | // Font Awesome 212 | display: inline-block; 213 | font: normal normal normal 14px/1 FontAwesome; 214 | font-size: inherit; 215 | text-rendering: auto; 216 | -webkit-font-smoothing: antialiased; 217 | -moz-osx-font-smoothing: grayscale; 218 | transform: translate(0, 0); 219 | 220 | // Icon 221 | color: #757575; 222 | content: "\f056"; 223 | } 224 | 225 | .authentication dd[data-state="error"] > a:before, 226 | .list .state[data-state="error"]:before { 227 | color: #BD2820; 228 | content: "\f06a"; 229 | } 230 | 231 | .authentication dd[data-state="warning"] > a:before, 232 | .list .state[data-state="warning"]:before { 233 | color: #BDB820; 234 | content: "\f06a"; 235 | } 236 | 237 | .authentication dd[data-state="valid"] > a:before, 238 | .list .state[data-state="valid"]:before { 239 | color: #4FA51C; 240 | content: "\f058"; 241 | } 242 | 243 | .authentication dd > a:before { 244 | // Alignment 245 | margin-right: 8px; 246 | } 247 | } 248 | 249 | .accounts > .options { 250 | margin-top: 0; 251 | margin-bottom: 80px; 252 | } 253 | 254 | .accounts > .options .group:first-child .header { 255 | margin-top: 0; 256 | } 257 | -------------------------------------------------------------------------------- /app/scripts/directives/plex/pin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .directive('coPlexPin', function($timeout) { 5 | var intervalMinimum = 2000, 6 | intervalMaximum = 10000; 7 | 8 | function PlexPin($scope) { 9 | this.$scope = $scope; 10 | 11 | this.checks = 0; 12 | this.interval = intervalMinimum; 13 | this.timer = null; 14 | 15 | // Bind scope functions 16 | var self = this; 17 | 18 | $scope.$on('activate', function() { 19 | // Create new pin 20 | self.create(); 21 | }); 22 | 23 | $scope.$on('reset', function() { 24 | self.reset(); 25 | }); 26 | } 27 | 28 | PlexPin.prototype.reset = function() { 29 | var $scope = this.$scope; 30 | 31 | // Cancel timer 32 | if(this.timer !== null) { 33 | $timeout.cancel(this.timer); 34 | this.timer = null; 35 | } 36 | 37 | // Reset handler 38 | this.checks = 0; 39 | this.interval = intervalMinimum; 40 | 41 | // Reset scope 42 | $scope.current = null; 43 | $scope.enabled = true; 44 | $scope.expires_at = null; 45 | $scope.state = null; 46 | }; 47 | 48 | PlexPin.prototype.check = function() { 49 | console.debug('Checking pin status...'); 50 | 51 | var $scope = this.$scope, 52 | self = this; 53 | 54 | // Ensure pin authentication has been enabled 55 | if($scope.enabled !== true) { 56 | console.debug('PIN authentication has been cancelled'); 57 | self.reset(); 58 | return; 59 | } 60 | 61 | // Ensure pin details are valid 62 | if(typeof $scope.current === 'undefined' || $scope.current === null || $scope.current.id === null) { 63 | console.warn('Invalid pin data', $scope.current); 64 | self.reset(); 65 | return; 66 | } 67 | 68 | // Ensure pin hasn't expired 69 | if(new Date() > $scope.expires_at) { 70 | console.debug('Pin has expired'); 71 | self.reset(); 72 | return; 73 | } 74 | 75 | // Check if pin is authenticated 76 | this.checks += 1; 77 | 78 | plex.cloud["/pins"].get($scope.current.id).then(function(response) { 79 | var data = response.data; 80 | 81 | if(data.pin.auth_token._nil === 'true') { 82 | // PIN not authenticated yet, schedule next check 83 | self.schedule(); 84 | return; 85 | } 86 | 87 | // PIN has been authenticated 88 | $scope.$apply(function() { 89 | if($scope.enabled === true) { 90 | // Fire callback 91 | $scope.onAuthenticated({ 92 | token: data.pin.auth_token 93 | }); 94 | } 95 | 96 | $scope.state = 'complete'; 97 | }); 98 | }, function(data, status) { 99 | // Update state 100 | $scope.$apply(function() { 101 | $scope.state = 'complete'; 102 | }); 103 | 104 | // Fire callback 105 | $scope.onExpired(); 106 | }); 107 | }; 108 | 109 | PlexPin.prototype.create = function() { 110 | var $scope = this.$scope, 111 | self = this; 112 | 113 | $scope.state = 'create'; 114 | 115 | // Ensure pin authentication has been enabled 116 | if($scope.enabled !== true) { 117 | console.debug('PIN authentication has been cancelled'); 118 | return; 119 | } 120 | 121 | // Request new pin code 122 | console.debug('Creating new PIN...'); 123 | 124 | plex.cloud.pins().then(function(response) { 125 | var data = response.data; 126 | 127 | $scope.$apply(function() { 128 | // Update pin details 129 | $scope.current = data.pin; 130 | $scope.expires_at = new Date(data.pin.expires_at); 131 | 132 | $scope.state = 'check'; 133 | }); 134 | 135 | // Schedule pin check 136 | self.schedule(8000); 137 | }, function() { 138 | $scope.$apply(function() { 139 | $scope.state = 'error'; 140 | 141 | self.reset(); 142 | }); 143 | }) 144 | }; 145 | 146 | PlexPin.prototype.schedule = function(interval) { 147 | var self = this; 148 | 149 | // Cancel existing timer 150 | if(self.timer !== null) { 151 | $timeout.cancel(self.timer); 152 | self.timer = null; 153 | } 154 | 155 | // Increase interval 156 | this.interval = Math.round(intervalMinimum + ((this.checks / 3) * 1000)); 157 | 158 | if(this.interval > intervalMaximum) { 159 | // Ensure interval doesn't exceed the maximum 160 | this.interval = intervalMaximum; 161 | } 162 | 163 | // Use default interval 164 | if(typeof interval === 'undefined') { 165 | interval = this.interval; 166 | } 167 | 168 | // Schedule pin check 169 | self.timer = $timeout(function() { 170 | self.check(); 171 | }, interval); 172 | 173 | console.debug('Checking pin status in %sms', interval); 174 | }; 175 | 176 | return { 177 | restrict: 'E', 178 | scope: { 179 | onAuthenticated: '=coAuthenticated', 180 | onExpired: '=coExpired' 181 | }, 182 | templateUrl: 'directives/plex/pin.html', 183 | 184 | controller: function($scope) { 185 | // Set initial scope values 186 | $scope.current = null; 187 | $scope.enabled = true; 188 | $scope.expires_at = null; 189 | $scope.state = null; 190 | 191 | // Construct main controller 192 | var main = new PlexPin($scope); 193 | 194 | // Create initial pin 195 | main.create(); 196 | }, 197 | 198 | link: function(scope, element, attrs) { 199 | element.on('$destroy', function() { 200 | // Disable pin checks 201 | scope.enabled = false; 202 | }); 203 | } 204 | }; 205 | }); 206 | -------------------------------------------------------------------------------- /app/styles/directives/configuration/option.scss: -------------------------------------------------------------------------------- 1 | co-configuration-option { 2 | .controls { 3 | input[type="checkbox"] { 4 | margin: 0.4rem 0.4rem 0 0; 5 | } 6 | } 7 | 8 | .controls-inline { 9 | display: flex; 10 | 11 | margin-bottom: 8px; 12 | 13 | .title { 14 | flex: 1; 15 | } 16 | } 17 | 18 | .title { 19 | position: relative; 20 | height: 26px; 21 | 22 | label { 23 | padding-top: 2px; 24 | } 25 | 26 | a { 27 | display: none; 28 | height: 21px; 29 | 30 | position: absolute; 31 | top: 1px; 32 | 33 | margin: 0 0 0 6px; 34 | 35 | font-size: 16px; 36 | color: #CCC; 37 | } 38 | 39 | a:hover { 40 | color: #999; 41 | } 42 | } 43 | 44 | .description { 45 | display: none; 46 | width: 36%; 47 | 48 | position: fixed; 49 | top: 32%; 50 | left: 32%; 51 | 52 | padding: 1.0rem 1.4rem; 53 | 54 | background-color: #FFF; 55 | border-radius: 3px; 56 | border: solid 1px #666; 57 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.4); 58 | 59 | font-size: 0.88rem; 60 | line-height: 1.6; 61 | 62 | visibility: hidden; 63 | z-index: 1005; 64 | 65 | p, li { 66 | font-size: inherit; 67 | line-height: inherit; 68 | } 69 | 70 | p { 71 | margin: 0; 72 | padding: 0 0 1.0rem 0; 73 | } 74 | 75 | p:last-child { 76 | margin: 0; 77 | } 78 | 79 | p + ul, p + ol { 80 | margin-top: -0.5rem; 81 | } 82 | 83 | .close { 84 | float: right; 85 | margin: -16px -16px 0 6px; 86 | 87 | font-size: 32px; 88 | font-weight: bold; 89 | line-height: 1; 90 | 91 | color: #AAA; 92 | cursor: pointer; 93 | } 94 | 95 | .close:hover { 96 | color: #777; 97 | } 98 | 99 | ul { 100 | margin: 0 0 0 2rem; 101 | 102 | font-size: inherit; 103 | line-height: inherit; 104 | } 105 | 106 | table { 107 | width: 100%; 108 | margin: 0; 109 | 110 | border: none; 111 | border-spacing: 0; 112 | 113 | thead { 114 | display: none; 115 | } 116 | 117 | tbody tr { 118 | background: none; 119 | } 120 | 121 | tbody td { 122 | padding: 0.3625rem 0.625rem; 123 | 124 | font-size: inherit; 125 | line-height: inherit; 126 | 127 | border-top: 1px solid #CCC; 128 | color: inherit; 129 | } 130 | 131 | tbody td:first-child { 132 | padding: 0.3625rem 1rem; 133 | 134 | border-right: 1px solid #CCC; 135 | 136 | white-space: nowrap; 137 | } 138 | } 139 | } 140 | 141 | .description-overlay { 142 | display: none; 143 | 144 | position: fixed; 145 | top: 0; 146 | left: 0; 147 | bottom: 0; 148 | right: 0; 149 | 150 | background: rgba(0, 0, 0, 0.45); 151 | z-index: 1004; 152 | } 153 | 154 | div.has-description { 155 | .title { 156 | label { 157 | display: inline-block; 158 | } 159 | } 160 | 161 | .description.opened { 162 | display: block; 163 | visibility: visible; 164 | } 165 | 166 | .description-overlay.opened { 167 | display: block; 168 | } 169 | } 170 | } 171 | 172 | co-configuration-option:hover { 173 | div.has-description { 174 | a { 175 | display: inline-block; 176 | } 177 | } 178 | } 179 | 180 | @media only screen and (max-width: 40em) { 181 | co-configuration-option { 182 | .description { 183 | width: 100%; 184 | 185 | top: 32%; 186 | left: 0; 187 | 188 | border: none; 189 | border-radius: 0; 190 | } 191 | } 192 | } 193 | 194 | @media only screen and (min-width: 40em) { 195 | co-configuration-option { 196 | .description { 197 | width: 52%; 198 | 199 | top: 32%; 200 | left: 24%; 201 | } 202 | } 203 | } 204 | 205 | @media only screen and (min-width: 64.0625em) { 206 | co-configuration-option { 207 | .controls { 208 | min-width: 50%; 209 | max-width: 50%; 210 | } 211 | 212 | .description { 213 | width: 36%; 214 | 215 | top: 32%; 216 | left: 32%; 217 | } 218 | } 219 | } 220 | 221 | @media only screen and (min-width: 96em) { 222 | co-configuration-option { 223 | .row { 224 | display: flex; 225 | } 226 | 227 | .controls { 228 | flex: 1; 229 | float: none; 230 | } 231 | 232 | .title a { 233 | display: none; 234 | } 235 | 236 | .description { 237 | display: none; 238 | flex: 2; 239 | 240 | position: absolute; 241 | min-height: 100%; 242 | width: 52.39%; 243 | top: auto; 244 | left: auto; 245 | 246 | padding: 0 10px 0 10px; 247 | margin: 0 0 0 100%; 248 | 249 | font-size: 0.78rem; 250 | 251 | box-shadow: none; 252 | 253 | border: none; 254 | border-radius: 0; 255 | 256 | background: none; 257 | color: #888; 258 | 259 | div { 260 | border: none; 261 | border-left: 2px solid #CCC; 262 | } 263 | 264 | p { 265 | padding: 0.5rem 0 0.5rem 1rem; 266 | } 267 | 268 | .close { 269 | display: none; 270 | visibility: hidden; 271 | } 272 | } 273 | 274 | .description-overlay { 275 | display: none; 276 | } 277 | 278 | div.has-description { 279 | .title { 280 | label { 281 | display: block; 282 | } 283 | 284 | a { 285 | display: none; 286 | } 287 | } 288 | 289 | .description.opened { 290 | display: none; 291 | visibility: hidden; 292 | } 293 | 294 | .description:hover, .description.visible { 295 | display: flex; 296 | visibility: visible; 297 | } 298 | 299 | .description-overlay.opened { 300 | display: none; 301 | } 302 | } 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /app/scripts/models/configuration/rules/client/collection.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .factory('ClientRuleCollection', function(ClientRule, $q, $rootScope, $timeout) { 5 | var accountFunctions = [ 6 | { $order: 1, value: '-', text: 'None' } 7 | ], 8 | attributeFunctions = [ 9 | { $order: 1, value: '*', text: 'Any' } 10 | ]; 11 | 12 | function ClientRuleCollection() { 13 | this.available = { 14 | keys: null, 15 | names: null, 16 | addresses: null 17 | }; 18 | 19 | this.accounts = null; 20 | this.accountsById = null; 21 | 22 | this.original = null; 23 | this.rules = null; 24 | } 25 | 26 | ClientRuleCollection.prototype.create = function() { 27 | // Reset rule states to "view" 28 | this.reset(); 29 | 30 | // Create new rule 31 | var rule = new ClientRule( 32 | this, 33 | { 34 | key: '*', 35 | name: '*', 36 | address: '*', 37 | 38 | account: { 39 | id: '-', 40 | name: 'None' 41 | }, 42 | priority: this.rules.length + 1 43 | }, 44 | 'edit' 45 | ); 46 | 47 | this.rules.push(rule); 48 | 49 | // Focus new rule 50 | $timeout(function() { 51 | rule.focus(); 52 | }) 53 | }; 54 | 55 | ClientRuleCollection.prototype.delete = function(rule) { 56 | var index = this.rules.indexOf(rule); 57 | 58 | if(index === -1) { 59 | return; 60 | } 61 | 62 | // Delete rule from collection 63 | this.rules.splice(index, 1); 64 | 65 | // Update rule priorities 66 | this.updatePriorities(); 67 | }; 68 | 69 | ClientRuleCollection.prototype.discard = function() { 70 | // Discard rule changes 71 | this.updateRules(this.original); 72 | 73 | // Update rule priorities 74 | this.updatePriorities(); 75 | 76 | return $q.resolve(); 77 | }; 78 | 79 | ClientRuleCollection.prototype.refresh = function() { 80 | var self = this; 81 | 82 | return $q.all([ 83 | // Retrieve seen clients 84 | $rootScope.$s.call('session.client.list').then( 85 | $.proxy(self.updateClients, self), 86 | function() { 87 | return $q.reject('Unable to retrieve clients'); 88 | } 89 | ), 90 | 91 | // Retrieve client rules 92 | $rootScope.$s.call('session.client.rule.list', [], {full: true}).then( 93 | $.proxy(self.updateRules, self), 94 | function() { 95 | return $q.reject('Unable to retrieve client rules'); 96 | } 97 | ) 98 | ]); 99 | }; 100 | 101 | ClientRuleCollection.prototype.reset = function() { 102 | // Reset rules back to view mode 103 | _.each(this.rules, function(rule) { 104 | rule.save(); 105 | }); 106 | }; 107 | 108 | ClientRuleCollection.prototype.save = function() { 109 | var self = this, 110 | current = _.map(this.rules, function(rule) { 111 | return rule.current(); 112 | }); 113 | 114 | return $rootScope.$s.call('session.client.rule.update', [], {current: current, full: true}).then( 115 | $.proxy(self.updateRules, self), 116 | function() { 117 | return $q.reject('Unable to update client rules'); 118 | } 119 | ); 120 | }; 121 | 122 | ClientRuleCollection.prototype.renderClient = function(item, escape) { 123 | if(item.type !== 'key') { 124 | return '
' + escape(item.text) + '
'; 125 | } 126 | 127 | return ( 128 | '
' + 129 | '
' + escape(item.name) + '
' + 130 | '' + escape(item.product) + ' / ' + escape(item.text) + '' + 131 | '
' 132 | ); 133 | }; 134 | 135 | ClientRuleCollection.prototype.updateAccounts = function(accounts) { 136 | this.accounts = [].concat(accountFunctions, accounts); 137 | 138 | this.accountsById = _.indexBy(this.accounts, 'value'); 139 | }; 140 | 141 | ClientRuleCollection.prototype.updateClients = function(clients) { 142 | // Build collection of client keys 143 | this.available.keys = [].concat(attributeFunctions, _.map(clients, function (client) { 144 | return { 145 | $order: 10, 146 | type: 'key', 147 | 148 | value: client.key, 149 | text: client.key, 150 | 151 | // Extra metadata 152 | name: client.name, 153 | platform: client.platform, 154 | product: client.product 155 | }; 156 | })); 157 | 158 | // Build collection of client names 159 | this.available.names = [].concat(attributeFunctions, _.map(clients, function (client) { 160 | return { 161 | $order: 10, 162 | type: 'name', 163 | 164 | value: client.name, 165 | text: client.name 166 | }; 167 | })); 168 | 169 | // Build collection of client addresses 170 | this.available.addresses = [].concat(attributeFunctions, _.map(clients, function (client) { 171 | return { 172 | $order: 10, 173 | type: 'address', 174 | 175 | value: client.address, 176 | text: client.address 177 | }; 178 | })); 179 | }; 180 | 181 | ClientRuleCollection.prototype.updatePriorities = function() { 182 | // Update rule priorities 183 | for(var i = 0; i < this.rules.length; ++i) { 184 | this.rules[i].priority = i + 1; 185 | } 186 | }; 187 | 188 | ClientRuleCollection.prototype.updateRules = function(rules) { 189 | var self = this; 190 | 191 | this.original = angular.copy(rules); 192 | 193 | // Parse rules 194 | this.rules = _.map(rules, function(rule) { 195 | return new ClientRule(self, rule); 196 | }); 197 | }; 198 | 199 | return ClientRuleCollection; 200 | }); 201 | -------------------------------------------------------------------------------- /app/scripts/directives/plex/login.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .directive('coPlexLogin', function(Utils, $q) { 5 | function PlexLogin($scope) { 6 | this.$scope = $scope; 7 | 8 | // Bind functions 9 | var self = this; 10 | 11 | $scope.$on('reset', function() { 12 | self.reset(); 13 | }); 14 | 15 | $scope.basicLogin = function() { 16 | return self.basicLogin($scope.credentials); 17 | }; 18 | 19 | $scope.onHomeAuthenticated = function(id, pin) { 20 | self.onHomeAuthenticated(id, pin); 21 | }; 22 | 23 | $scope.onPinAuthenticated = function(credentials) { 24 | self.onPinAuthenticated(credentials); 25 | }; 26 | 27 | $scope.onPinExpired = function() { 28 | self.onPinExpired(); 29 | }; 30 | 31 | $scope.switch = function(method) { 32 | $scope.messages = []; 33 | $scope.method = method; 34 | }; 35 | } 36 | 37 | PlexLogin.prototype.reset = function() { 38 | var $scope = this.$scope; 39 | 40 | // Reset scope values 41 | $scope.credentials = { 42 | username: null, 43 | password: null 44 | }; 45 | 46 | $scope.messages = []; 47 | $scope.method = 'pin'; 48 | }; 49 | 50 | PlexLogin.prototype.appendMessage = function(type, content) { 51 | var $scope = this.$scope; 52 | 53 | $scope.messages.push({ 54 | type: type, 55 | content: content 56 | }); 57 | }; 58 | 59 | PlexLogin.prototype.basicLogin = function(credentials) { 60 | var $scope = this.$scope, 61 | self = this; 62 | 63 | // Reset errors 64 | $scope.messages = []; 65 | 66 | if(!Utils.isDefined(credentials) || 67 | !Utils.isDefined(credentials.username) || 68 | !Utils.isDefined(credentials.password)) { 69 | self.appendMessage('error', 'Invalid basic login request'); 70 | return $q.reject(); 71 | } 72 | 73 | // Perform login 74 | return plex.cloud['/users'].login( 75 | credentials.username, 76 | credentials.password 77 | ).then( 78 | function(response) { 79 | $scope.$apply(function() { 80 | self.handleSuccess(response.data.user); 81 | }); 82 | }, 83 | function(response) { 84 | $scope.$apply(function() { 85 | self.handleError(response.data, response.statusCode); 86 | }); 87 | 88 | return $q.reject(); 89 | } 90 | ); 91 | }; 92 | 93 | PlexLogin.prototype.onHomeAuthenticated = function(id, pin) { 94 | var $scope = this.$scope, 95 | self = this; 96 | 97 | // Reset errors 98 | $scope.messages = []; 99 | 100 | if(!Utils.isDefined(id)) { 101 | self.appendMessage('error', 'Invalid user switch request'); 102 | return; 103 | } 104 | 105 | if(Utils.isDefined(pin) && isNaN(pin)) { 106 | self.appendMessage('error', 'PIN contains invalid characters, only numbers are allowed'); 107 | return; 108 | } 109 | 110 | // Retrieve account details 111 | plex.cloud['/api/home/users'].switch(id, pin).then(function(response) { 112 | $scope.$apply(function() { 113 | self.handleSuccess(response.data.user); 114 | }); 115 | }, function(response) { 116 | $scope.$apply(function() { 117 | self.handleError(response.data, response.statusCode); 118 | }); 119 | }); 120 | }; 121 | 122 | PlexLogin.prototype.onPinAuthenticated = function(credentials) { 123 | var $scope = this.$scope, 124 | self = this; 125 | 126 | // Reset errors 127 | $scope.messages = []; 128 | 129 | if(!Utils.isDefined(credentials) || 130 | !Utils.isDefined(credentials.token)) { 131 | self.appendMessage('error', 'Invalid pin login request'); 132 | return; 133 | } 134 | 135 | // Retrieve account details 136 | plex.cloud['/users'].account( 137 | credentials.token, { 138 | plex: { 139 | useToken: false 140 | } 141 | } 142 | ).then( 143 | function(response) { 144 | $scope.$apply(function() { 145 | self.handleSuccess(response.data.user); 146 | }); 147 | }, 148 | function(response) { 149 | $scope.$apply(function() { 150 | self.handleError(response.data, response.statusCode); 151 | }); 152 | } 153 | ); 154 | }; 155 | 156 | PlexLogin.prototype.onPinExpired = function() { 157 | var $scope = this.$scope; 158 | 159 | this.appendMessage('error', 'Pin has expired'); 160 | }; 161 | 162 | PlexLogin.prototype.handleSuccess = function(user) { 163 | var $scope = this.$scope; 164 | 165 | // Pass to callback 166 | $scope.onAuthenticated(user._authenticationToken, user); 167 | }; 168 | 169 | PlexLogin.prototype.handleError = function(data, status) { 170 | var $scope = this.$scope; 171 | 172 | // Login failed 173 | if( 174 | Utils.isDefined(data) && 175 | Utils.isDefined(data.errors) && 176 | Utils.isDefined(data.errors.error) 177 | ) { 178 | // "Error" elements found 179 | var errors = typeof data.errors.error === 'object' ? data.errors.error : [data.errors.error]; 180 | 181 | for (var i = 0; i < errors.length; ++i) { 182 | this.appendMessage('error', errors[i]); 183 | } 184 | } else if( 185 | Utils.isDefined(data.Response) && 186 | Utils.isDefined(data.Response._status) 187 | ) { 188 | // "Response" element found (home user switch) 189 | this.appendMessage('error', data.Response._status); 190 | } else { 191 | // Display HTTP error 192 | this.appendMessage('error', 'HTTP Error: ' + status); 193 | } 194 | }; 195 | 196 | return { 197 | restrict: 'E', 198 | scope: { 199 | onAuthenticated: '=coAuthenticated', 200 | 201 | isCancelEnabled: '=coCancelEnabled', 202 | onCancelled: '=coCancelled', 203 | 204 | buttonSize: '@coButtonSize', 205 | modes: '=coModes' 206 | }, 207 | templateUrl: 'directives/plex/login.html', 208 | 209 | compile: function(element, attrs) { 210 | if(typeof attrs.coButtonSize === 'undefined') { 211 | attrs.coButtonSize = 'small'; 212 | } 213 | }, 214 | 215 | controller: function($scope) { 216 | // Set initial scope values 217 | $scope.credentials = { 218 | username: null, 219 | password: null 220 | }; 221 | 222 | $scope.messages = []; 223 | $scope.method = 'pin'; 224 | 225 | // Construct main controller 226 | var main = new PlexLogin($scope); 227 | } 228 | }; 229 | }); 230 | -------------------------------------------------------------------------------- /app/views/configuration/rules.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 35 | 55 | 78 | 98 | 118 | 119 | 120 | 121 | 122 | 125 | 126 | 127 |
AccountClientNameAddress
25 |
26 | 27 | 28 |
29 |
30 | 31 | 32 | 33 |
34 |
36 |
37 | 48 | 49 |
50 |
51 | {{ rule.account.name }} 52 | deleted 53 |
54 |
56 |
57 | 72 | 73 |
74 |
75 | {{ attributeLabel(rule.key) }} 76 |
77 |
79 |
80 | 92 | 93 |
94 |
95 | {{ attributeLabel(rule.name) }} 96 |
97 |
99 |
100 | 112 | 113 |
114 |
115 | {{ attributeLabel(rule.address) }} 116 |
117 |
123 | 124 |
128 |
129 | 130 |
131 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 162 | 182 | 202 | 203 | 204 | 205 | 206 | 207 | 210 | 211 | 212 |
AccountName
152 |
153 | 154 | 155 |
156 |
157 | 158 | 159 | 160 |
161 |
163 |
164 | 175 | 176 |
177 |
178 | {{ rule.account.name }} 179 | deleted 180 |
181 |
183 |
184 | 196 | 197 |
198 |
199 | {{ attributeLabel(rule.name) }} 200 |
201 |
208 | 209 |
213 |
214 |
215 | -------------------------------------------------------------------------------- /app/scripts/models/plex/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('configurationApp') 4 | .factory('PlexServer', function(PlexConnection, PlexConnectionManager, Utils, VersionUtil, $location, $q, $rootScope) { 5 | var identifier = 'com.plexapp.plugins.trakttv', 6 | pluginVersionMinimum = '1.0.3.0', 7 | target = 'MessageKit:Api'; 8 | 9 | function parseErrorResponse(response) { 10 | if(Utils.isDefined(response.reason)) { 11 | return response.reason; 12 | } 13 | 14 | if(Utils.isDefined(response.data) && Utils.isDefined(response.data.error)) { 15 | // Retrieve error from data 16 | var error = response.data.error; 17 | 18 | if(Utils.isDefined(error.message)) { 19 | return error.message; 20 | } 21 | } 22 | 23 | if(Utils.isDefined(response.statusCode)) { 24 | return 'HTTP Error: ' + response.statusCode; 25 | } 26 | 27 | return 'Unknown error'; 28 | } 29 | 30 | function PlexServer() { 31 | this.name = null; 32 | 33 | this.identifier = null; 34 | this.plugin_version = null; 35 | 36 | this.token_channel = null; 37 | this.token_channel_expire = null; 38 | 39 | this.token_plex = null; 40 | 41 | this.client = null; 42 | this.connection_manager = null; 43 | 44 | this.status = null; 45 | } 46 | 47 | PlexServer.prototype.isAuthenticated = function() { 48 | return this.token_channel !== null; 49 | }; 50 | 51 | PlexServer.prototype.authenticate = function() { 52 | var self = this; 53 | 54 | // Reset connection "error" 55 | self.status = null; 56 | 57 | // Authenticate with plugin 58 | return this.call('system.authenticate', [this.token_plex]).then(function(token) { 59 | if(token['X-Channel-Token'] === null || token['X-Channel-Token-Expire'] === null) { 60 | // Clear authentication details 61 | self.clearAuthentication(); 62 | 63 | // Reject promise 64 | self.status = 'Unable to authenticate with plugin'; 65 | return $q.reject(null); 66 | } 67 | 68 | // Retrieve authentication details 69 | self.token_channel = token['X-Channel-Token']; 70 | self.token_channel_expire = token['X-Channel-Token-Expire']; 71 | 72 | // Save server details 73 | self.save(); 74 | }, function(response) { 75 | // Clear authentication details 76 | self.clearAuthentication(); 77 | 78 | // Update status 79 | self.status = parseErrorResponse(response); 80 | 81 | return $q.reject(); 82 | }); 83 | }; 84 | 85 | PlexServer.prototype.clearAuthentication = function() { 86 | // Reset authentication details 87 | this.token_channel = null; 88 | this.token_channel_expire = null; 89 | 90 | // Save server details 91 | this.save(); 92 | }; 93 | 94 | PlexServer.prototype.disconnect = function() { 95 | // Clear current authentication details 96 | this.clearAuthentication(); 97 | 98 | // Clear current server 99 | $rootScope.$s = null; 100 | 101 | // Redirect to server connect view 102 | $location.path('/connect'); 103 | $location.search(''); 104 | }; 105 | 106 | PlexServer.prototype.connect = function() { 107 | var self = this; 108 | 109 | // Reset connection "error" 110 | self.status = null; 111 | 112 | // Test connections 113 | return this.connection_manager.test().then(function(connection) { 114 | // Check server 115 | return self.check().then(function() { 116 | return connection; 117 | }, function(response) { 118 | // Server didn't pass validation 119 | self.status = parseErrorResponse(response); 120 | 121 | return $q.reject(self.status); 122 | }); 123 | 124 | }, function(reason) { 125 | // Unable to connect to server 126 | self.status = reason; 127 | 128 | return $q.reject(self.status); 129 | }); 130 | }; 131 | 132 | PlexServer.prototype.check = function() { 133 | var self = this; 134 | 135 | return this.call('system.ping').then(function(pong) { 136 | // Store server version 137 | self.plugin_version = pong.version; 138 | 139 | // Check plugin meets version requirement 140 | if(VersionUtil.compare(self.plugin_version, pluginVersionMinimum) >= 0) { 141 | return true; 142 | } 143 | 144 | // Plugin update required 145 | return $q.reject({ 146 | reason: 'Plugin needs to be updated to v' + pluginVersionMinimum + ' or later' 147 | }); 148 | }, function(response) { 149 | // Unable to ping server 150 | return $q.reject(response); 151 | }); 152 | }; 153 | 154 | PlexServer.prototype.call = function(key, args, kwargs) { 155 | args = typeof args !== 'undefined' ? args : []; 156 | 157 | // insert `key` at the front of `args` 158 | args.splice(0, 0, key); 159 | 160 | // build headers 161 | var headers = {}; 162 | 163 | if(this.token_channel !== null) { 164 | headers['X-Channel-Token'] = this.token_channel; 165 | } 166 | 167 | console.debug('[%s] Request "%s"', this.identifier, key, { 168 | args: args, 169 | kwargs: kwargs 170 | }); 171 | 172 | // call api function 173 | var deferred = $q.defer(), 174 | self = this; 175 | 176 | this.client['/:/plugins/*/messaging'].callFunction( 177 | identifier, target, args, kwargs, { 178 | headers: headers 179 | } 180 | ).then(function(response) { 181 | var data = response.data; 182 | 183 | // Parse response 184 | if(typeof data === 'string') { 185 | data = JSON.parse(data); 186 | } else if(typeof data === 'object') { 187 | console.warn('Legacy response format returned'); 188 | } 189 | 190 | // Return response 191 | console.debug('[%s] Response', self.identifier, data); 192 | 193 | if(data.result !== undefined) { 194 | deferred.resolve(data.result); 195 | return; 196 | } 197 | 198 | // Handle errors 199 | if(data.error !== undefined) { 200 | if(data.error.code == 'error.authentication.required') { 201 | // Authentication token invalid, redirect to the connect view 202 | self.disconnect(); 203 | } 204 | 205 | // Reject request with error 206 | deferred.reject(data.error); 207 | } else { 208 | deferred.reject(null); 209 | } 210 | }, function(response) { 211 | deferred.reject(response); 212 | }); 213 | 214 | return deferred.promise; 215 | }; 216 | 217 | PlexServer.prototype.get = function(path, config) { 218 | config = typeof config !== 'undefined' ? config : {}; 219 | 220 | config.method = 'GET'; 221 | 222 | config.headers = typeof config.headers !== 'undefined' ? config.headers : {}; 223 | config.headers['X-Plex-Token'] = this.token_plex; 224 | 225 | return this.current.request(path, config); 226 | }; 227 | 228 | PlexServer.prototype._attributeKey = function(name) { 229 | return 'server.' + this.identifier + '.' + name; 230 | }; 231 | 232 | PlexServer.prototype.load = function() { 233 | if(this.identifier === null || typeof this.identifier === 'undefined') { 234 | return; 235 | } 236 | 237 | var self = this; 238 | 239 | function loadAttribute(name) { 240 | var value = localStorage[self._attributeKey(name)]; 241 | 242 | if(value === null || typeof value === 'undefined') { 243 | return; 244 | } 245 | 246 | self[name] = value; 247 | } 248 | 249 | loadAttribute('token_channel'); 250 | loadAttribute('token_channel_expire'); 251 | }; 252 | 253 | PlexServer.prototype.save = function() { 254 | if(this.identifier === null || typeof this.identifier === 'undefined') { 255 | return; 256 | } 257 | 258 | var self = this; 259 | 260 | function saveAttribute(name) { 261 | localStorage[self._attributeKey(name)] = self[name]; 262 | } 263 | 264 | saveAttribute('token_channel'); 265 | saveAttribute('token_channel_expire'); 266 | }; 267 | 268 | PlexServer.fromElement = function(e) { 269 | var s = new PlexServer(); 270 | 271 | // Set attributes 272 | s.name = e._name; 273 | 274 | s.identifier = e._clientIdentifier; 275 | s.token_plex = e._accessToken; 276 | 277 | if(typeof e.Connection.length === 'undefined') { 278 | e.Connection = [e.Connection]; 279 | } 280 | 281 | // Build `Connection` objects 282 | var connections = _.map(e.Connection, function(e) { 283 | return PlexConnection.fromElement(e); 284 | }); 285 | 286 | s.connection_manager = new PlexConnectionManager(s, connections); 287 | 288 | // Load attributes from storage 289 | s.load(); 290 | 291 | return s; 292 | }; 293 | 294 | return PlexServer; 295 | }); 296 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Configuration - trakt (for Plex) 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 28 | 29 | 30 |
    31 |
  • 32 |

    Configuration

    33 |
  • 34 |
  • Menu
  • 35 |
36 | 37 | 38 |
    39 |
  • Server
  • 40 |
  • Accounts
  • 41 |
  • Rules
  • 42 |
43 | 44 |
    45 |
  • {{ $s.name }}
  • 46 |
  • 47 | {{ $a.user.username }} 48 |
      49 |
    • Logout
    • 50 |
    51 |
  • 52 | 53 |
  • 54 | Support 55 |
      56 |
    • 57 | Wiki 58 |
    • 59 |
    • 60 | Issues 61 |
    • 62 |
    • 63 | Report an issue 64 |
    • 65 |
    66 |
  • 67 |
68 |
69 |
70 | 71 |
72 | 73 |
74 |
75 | 76 | 77 | 78 | 79 |
80 | Version: {{ $m.version }}, Revision: {{ $m.revision.label }} 81 |
82 |
83 | 84 |
85 | 86 | 87 | 88 | 89 |
90 |
91 | Connected to {{ $s.name }} via {{ $s.connection_manager.current.uri }} 92 |
93 |
94 | Not connected 95 |
96 |
97 |
98 |
99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 116 | 117 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | --------------------------------------------------------------------------------