ソースを参照

Merge pull request #1 from causefx/v2-develop

V2 develop
TehMuffinMoo 5 年 前
コミット
a0733155a4
100 ファイル変更8385 行追加3782 行削除
  1. 14 0
      .github/workflows/lock.yml
  2. 19 0
      .github/workflows/stale.yml
  3. 11 1
      .gitignore
  4. 4 3
      README.md
  5. 325 574
      api/classes/organizr.class.php
  6. 3 2
      api/composer.json
  7. 322 57
      api/composer.lock
  8. 46 7
      api/config/default.php
  9. 164 6
      api/functions/auth-functions.php
  10. 64 2
      api/functions/backup-functions.php
  11. 1 151
      api/functions/homepage-connect-functions.php
  12. 93 1
      api/functions/homepage-functions.php
  13. 20 6
      api/functions/log-functions.php
  14. 22 8
      api/functions/normal-functions.php
  15. 103 0
      api/functions/oauth.php
  16. 158 156
      api/functions/organizr-functions.php
  17. 112 9
      api/functions/sso-functions.php
  18. 1 6
      api/functions/static-globals.php
  19. 6 6
      api/functions/token-functions.php
  20. 53 1
      api/homepage/calendar.php
  21. 26 14
      api/homepage/couchpotato.php
  22. 52 21
      api/homepage/deluge.php
  23. 250 210
      api/homepage/emby.php
  24. 55 27
      api/homepage/healthchecks.php
  25. 145 0
      api/homepage/html.php
  26. 119 0
      api/homepage/jackett.php
  27. 45 11
      api/homepage/jdownloader.php
  28. 246 206
      api/homepage/jellyfin.php
  29. 45 40
      api/homepage/lidarr.php
  30. 32 0
      api/homepage/misc.php
  31. 61 31
      api/homepage/monitorr.php
  32. 41 10
      api/homepage/netdata.php
  33. 43 9
      api/homepage/nzbget.php
  34. 41 13
      api/homepage/octoprint.php
  35. 62 50
      api/homepage/ombi.php
  36. 40 9
      api/homepage/pihole.php
  37. 451 285
      api/homepage/plex.php
  38. 51 11
      api/homepage/qbittorrent.php
  39. 128 49
      api/homepage/radarr.php
  40. 78 42
      api/homepage/rtorrent.php
  41. 46 41
      api/homepage/sabnzbd.php
  42. 25 18
      api/homepage/sickrage.php
  43. 80 39
      api/homepage/sonarr.php
  44. 40 13
      api/homepage/speedtest.php
  45. 55 16
      api/homepage/tautulli.php
  46. 359 0
      api/homepage/trakt.php
  47. 56 13
      api/homepage/transmission.php
  48. 51 26
      api/homepage/unifi.php
  49. 43 11
      api/homepage/weather.php
  50. 3 39
      api/pages/homepage.php
  51. 143 140
      api/pages/login.php
  52. 3 1
      api/pages/settings-image-manager.php
  53. 55 0
      api/pages/settings-settings-backup.php
  54. 0 31
      api/pages/settings-settings-sso.php
  55. 131 70
      api/pages/settings-tab-editor-categories.php
  56. 3 2
      api/pages/settings-tab-editor-homepage.php
  57. 217 169
      api/pages/settings-tab-editor-tabs.php
  58. 7 2
      api/pages/settings-user-manage-users.php
  59. 36 13
      api/pages/settings.php
  60. 35 0
      api/pages/tabs.php
  61. 6 7
      api/pages/wizard.php
  62. 205 0
      api/plugins/api/bookmark.php
  63. 1079 0
      api/plugins/bookmark.php
  64. 20 12
      api/plugins/chat.php
  65. 4 0
      api/plugins/config/bookmark.php
  66. 70 0
      api/plugins/css/bookmark.css
  67. 18 10
      api/plugins/healthChecks.php
  68. 4 2
      api/plugins/invites.php
  69. 571 0
      api/plugins/js/bookmark-settings.js
  70. 168 182
      api/plugins/js/chat.js
  71. 110 0
      api/plugins/js/healthChecks-settings.js
  72. 1 110
      api/plugins/js/healthChecks.js
  73. 398 422
      api/plugins/js/invites.js
  74. 205 224
      api/plugins/js/php-mailer.js
  75. 57 72
      api/plugins/js/speedTest.js
  76. 4 2
      api/plugins/php-mailer.php
  77. 5 3
      api/plugins/speedTest.php
  78. 6 0
      api/v2/index.php
  79. 41 0
      api/v2/routes/backup.php
  80. 46 0
      api/v2/routes/connectionTester.php
  81. 27 0
      api/v2/routes/custom/index.html
  82. 1 1
      api/v2/routes/emby.php
  83. 13 0
      api/v2/routes/help.php
  84. 24 50
      api/v2/routes/homepage.php
  85. 12 0
      api/v2/routes/icon.php
  86. 11 0
      api/v2/routes/image.php
  87. 25 0
      api/v2/routes/news.php
  88. 13 0
      api/v2/routes/oauth.php
  89. 9 0
      api/v2/routes/opencollective.php
  90. 3 2
      api/v2/routes/root.php
  91. 10 0
      api/v2/routes/settings.php
  92. 11 0
      api/v2/routes/tabs.php
  93. 0 1
      api/vendor/adldap2/adldap2/.travis.yml
  94. 3 3
      api/vendor/adldap2/adldap2/composer.json
  95. 6 0
      api/vendor/adldap2/adldap2/readme.md
  96. 110 0
      api/vendor/adldap2/adldap2/src/Schemas/Directory389.php
  97. 110 0
      api/vendor/adldap2/adldap2/src/Schemas/EDirectory.php
  98. 1 1
      api/vendor/adldap2/adldap2/src/Utilities.php
  99. 3 0
      api/vendor/bogstag/oauth2-trakt/.gitignore
  100. 35 0
      api/vendor/bogstag/oauth2-trakt/.scrutinizer.yml

+ 14 - 0
.github/workflows/lock.yml

@@ -0,0 +1,14 @@
+name: Lock closed stale issue
+
+on:
+  issues:
+    types: [closed]
+
+jobs:
+  lock:
+    if: github.event.label.name == 'closed-no-issue-activity'
+    runs-on: ubuntu-latest
+    steps:
+      - uses: OSDKDev/lock-issues@v1.1
+        with:
+          repo-token: "${{ secrets.GITHUB_TOKEN }}"

+ 19 - 0
.github/workflows/stale.yml

@@ -0,0 +1,19 @@
+name: Close stale issues
+
+on:
+  schedule:
+    - cron: "30 3 * * *"
+
+jobs:
+  stale:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/stale@v3
+        with:
+          repo-token: ${{ secrets.GITHUB_TOKEN }}
+          stale-issue-message: "This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs."
+          close-issue-message: "This issue has been closed due to lack of activity, if this issue still persists, please re-open it."
+          close-issue-label: "closed-no-issue-activity"
+          stale-issue-label: "no-issue-activity"
+          days-before-stale: 15
+          days-before-close: 90

+ 11 - 1
.gitignore

@@ -84,6 +84,7 @@ config/users
 config/users*.db
 config/users*.bak.db
 config/tmp/*
+docs/api.json
 images/cache/*
 backups/*
 backups/
@@ -118,6 +119,8 @@ plugins/plugin_files/*
 !plugins/plugin_files/index.html
 plugins/images/userTabs/*
 !plugins/images/userTabs/index.html
+api/v2/routes/custom/*
+!api/v2/routes/custom/index.html
 # =========================
 # Plugin files
 # =========================
@@ -134,6 +137,7 @@ api/plugins/*
 !api/plugins/api/healthChecks.php
 !api/plugins/config/healthChecks.php
 !api/plugins/js/healthChecks.js
+!api/plugins/js/healthChecks-settings.js
 !api/plugins/php-mailer.php
 !api/plugins/api/php-mailer.php
 !api/plugins/config/php-mailer.php
@@ -153,6 +157,11 @@ api/plugins/*
 !api/plugins/misc/speedTest/telemetry.php
 !api/plugins/misc/speedTest/telemetry_settings.php
 !api/plugins/misc/speedTest/speedtest_worker.min.js
+!api/plugins/bookmark.php
+!api/plugins/api/bookmark.php
+!api/plugins/config/bookmark.php
+!api/plugins/js/bookmark-settings.js
+!api/plugins/css/bookmark.css
 
 # =========================
 # Custom files
@@ -164,4 +173,5 @@ api/pages/custom/*.php
 /plugins/images/cache/tautulli-artist.svg
 /plugins/images/cache/tautulli-movie.svg
 /plugins/images/cache/tautulli-windows.svg
-plugins/images/cache/tautulli-samsung.svg
+/plugins/images/cache/tautulli-samsung.svg
+/plugins/images/cache/tautulli-chrome.svg

+ 4 - 3
README.md

@@ -4,7 +4,7 @@
 [![Average time to resolve an issue](http://isitmaintained.com/badge/resolution/causefx/Organizr.svg)](http://isitmaintained.com/project/causefx/Organizr "Average time to resolve an issue")
 [![GitHub stars](https://img.shields.io/github/stars/causefx/Organizr.svg)](https://github.com/causefx/Organizr/stargazers)
 [![GitHub forks](https://img.shields.io/github/forks/causefx/Organizr.svg)](https://github.com/causefx/Organizr/network)
-[![Docker pulls](https://img.shields.io/docker/pulls/organizrtools/organizr-v2.svg)](https://hub.docker.com/r/organizrtools/organizr-v2)
+[![Docker pulls](https://img.shields.io/docker/pulls/organizr/organizr.svg)](https://hub.docker.com/r/organizr/organizr)
 [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://paypal.me/causefx)
 [![Beerpay](https://beerpay.io/causefx/Organizr/badge.svg?style=beer-square)](https://beerpay.io/causefx/Organizr)
 [![Beerpay](https://beerpay.io/causefx/Organizr/make-wish.svg?style=flat-square)](https://beerpay.io/causefx/Organizr?focus=wish)
@@ -18,8 +18,9 @@ Do you have quite a bit of services running on your computer or server? Do you h
 - PHP 7.2+
 - [Official Site](https://organizr.app) - Will be refreshed soon!
 - [Official Discord](https://organizr.app/discord)
+
 - [See Wiki](https://docs.organizr.app/) - Will be updated soon!
-- [Docker](https://github.com/Organizr/docker-organizr)
+- [Docker](https://hub.docker.com/r/organizr/organizr)
 
 ![OrganizrGallery](https://user-images.githubusercontent.com/16184466/53614284-a9b73480-3b96-11e9-9bea-d7a30b294267.png)
 
@@ -104,4 +105,4 @@ The optional parameters and GID and UID are described in the [readme](https://gi
 
 ### This project is supported by
 
-<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="200px"></img>
+<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="200px"></img>

ファイルの差分が大きいため隠しています
+ 325 - 574
api/classes/organizr.class.php


+ 3 - 2
api/composer.json

@@ -12,8 +12,9 @@
     "pragmarx/google2fa": "^3.0",
     "psr/log": "^1.1",
     "adldap2/adldap2": "^10.0",
-    "slim/slim": "4.0",
+    "slim/slim": "^4.0",
     "slim/psr7": "^1.1",
-    "zircote/swagger-php": "^3.0"
+    "zircote/swagger-php": "^3.0",
+    "bogstag/oauth2-trakt": "^1.0"
   }
 }

+ 322 - 57
api/composer.lock

@@ -4,34 +4,34 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "a5faec9d74a923c354d912dbfd73cf9b",
+    "content-hash": "1620f8d6a2da9fd3302c753d432b19bc",
     "packages": [
         {
             "name": "adldap2/adldap2",
-            "version": "v10.3.0",
+            "version": "v10.3.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Adldap2/Adldap2.git",
-                "reference": "1294c92746e3fb3bb59cd7756ca7838a1e705a2a"
+                "reference": "936a4e2eb925d005198f716a75bb78068c4de94d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Adldap2/Adldap2/zipball/1294c92746e3fb3bb59cd7756ca7838a1e705a2a",
-                "reference": "1294c92746e3fb3bb59cd7756ca7838a1e705a2a",
+                "url": "https://api.github.com/repos/Adldap2/Adldap2/zipball/936a4e2eb925d005198f716a75bb78068c4de94d",
+                "reference": "936a4e2eb925d005198f716a75bb78068c4de94d",
                 "shasum": ""
             },
             "require": {
                 "ext-json": "*",
                 "ext-ldap": "*",
-                "illuminate/contracts": "~5.0|~6.0|~7.0",
+                "illuminate/contracts": "~5.0|~6.0|~7.0|~8.0",
                 "php": ">=7.0",
                 "psr/log": "~1.0",
                 "psr/simple-cache": "~1.0",
-                "tightenco/collect": "~5.0|~6.0|~7.0"
+                "tightenco/collect": "~5.0|~6.0|~7.0|~8.0"
             },
             "require-dev": {
                 "mockery/mockery": "~1.0",
-                "phpunit/phpunit": "~6.0"
+                "phpunit/phpunit": "~6.0|~7.0|~8.0"
             },
             "suggest": {
                 "ext-fileinfo": "fileinfo is required when retrieving user encoded thumbnails"
@@ -63,7 +63,68 @@
                 "ldap",
                 "windows"
             ],
-            "time": "2020-05-04T21:10:15+00:00"
+            "support": {
+                "docs": "https://github.com/Adldap2/Adldap2/blob/master/readme.md",
+                "email": "steven_bauman@outlook.com",
+                "issues": "https://github.com/Adldap2/Adldap2/issues",
+                "source": "https://github.com/Adldap2/Adldap2"
+            },
+            "time": "2020-09-09T12:55:51+00:00"
+        },
+        {
+            "name": "bogstag/oauth2-trakt",
+            "version": "v1.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/Bogstag/oauth2-trakt.git",
+                "reference": "fbb9253d9e317e84dc2b3f1253afc1dcbb4414a2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/Bogstag/oauth2-trakt/zipball/fbb9253d9e317e84dc2b3f1253afc1dcbb4414a2",
+                "reference": "fbb9253d9e317e84dc2b3f1253afc1dcbb4414a2",
+                "shasum": ""
+            },
+            "require": {
+                "league/oauth2-client": "^2.0",
+                "php": ">=5.6.0"
+            },
+            "require-dev": {
+                "mockery/mockery": "~0.9",
+                "phpunit/phpunit": "^5.0",
+                "squizlabs/php_codesniffer": "~2.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Bogstag\\OAuth2\\Client\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Bogstag",
+                    "email": "krister@bogstag.se",
+                    "homepage": "https://github.com/bogstag"
+                }
+            ],
+            "description": "trakt.tv OAuth 2.0 Client Provider for The PHP League OAuth2-Client",
+            "keywords": [
+                "Authentication",
+                "authorization",
+                "client",
+                "oauth",
+                "oauth2",
+                "trakt"
+            ],
+            "support": {
+                "issues": "https://github.com/Bogstag/oauth2-trakt/issues",
+                "source": "https://github.com/Bogstag/oauth2-trakt/tree/master"
+            },
+            "time": "2017-02-26T18:30:14+00:00"
         },
         {
             "name": "composer/semver",
@@ -773,6 +834,76 @@
             ],
             "time": "2019-05-24T18:30:49+00:00"
         },
+        {
+            "name": "league/oauth2-client",
+            "version": "2.6.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/thephpleague/oauth2-client.git",
+                "reference": "badb01e62383430706433191b82506b6df24ad98"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/badb01e62383430706433191b82506b6df24ad98",
+                "reference": "badb01e62383430706433191b82506b6df24ad98",
+                "shasum": ""
+            },
+            "require": {
+                "guzzlehttp/guzzle": "^6.0 || ^7.0",
+                "paragonie/random_compat": "^1 || ^2 || ^9.99",
+                "php": "^5.6 || ^7.0 || ^8.0"
+            },
+            "require-dev": {
+                "mockery/mockery": "^1.3",
+                "php-parallel-lint/php-parallel-lint": "^1.2",
+                "phpunit/phpunit": "^5.7 || ^6.0 || ^9.3",
+                "squizlabs/php_codesniffer": "^2.3 || ^3.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-2.x": "2.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "League\\OAuth2\\Client\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Alex Bilbie",
+                    "email": "hello@alexbilbie.com",
+                    "homepage": "http://www.alexbilbie.com",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Woody Gilk",
+                    "homepage": "https://github.com/shadowhand",
+                    "role": "Contributor"
+                }
+            ],
+            "description": "OAuth 2.0 Client Library",
+            "keywords": [
+                "Authentication",
+                "SSO",
+                "authorization",
+                "identity",
+                "idp",
+                "oauth",
+                "oauth2",
+                "single sign on"
+            ],
+            "support": {
+                "issues": "https://github.com/thephpleague/oauth2-client/issues",
+                "source": "https://github.com/thephpleague/oauth2-client/tree/2.6.0"
+            },
+            "time": "2020-10-28T02:03:40+00:00"
+        },
         {
             "name": "nikic/fast-route",
             "version": "v1.3.0",
@@ -1010,27 +1141,31 @@
         },
         {
             "name": "phpmailer/phpmailer",
-            "version": "v6.1.4",
+            "version": "v6.2.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/PHPMailer/PHPMailer.git",
-                "reference": "c5e61d0729507049cec9673aa1a679f9adefd683"
+                "reference": "e38888a75c070304ca5514197d4847a59a5c853f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/c5e61d0729507049cec9673aa1a679f9adefd683",
-                "reference": "c5e61d0729507049cec9673aa1a679f9adefd683",
+                "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/e38888a75c070304ca5514197d4847a59a5c853f",
+                "reference": "e38888a75c070304ca5514197d4847a59a5c853f",
                 "shasum": ""
             },
             "require": {
                 "ext-ctype": "*",
                 "ext-filter": "*",
+                "ext-hash": "*",
                 "php": ">=5.5.0"
             },
             "require-dev": {
+                "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0",
                 "doctrine/annotations": "^1.2",
-                "friendsofphp/php-cs-fixer": "^2.2",
-                "phpunit/phpunit": "^4.8 || ^5.7"
+                "phpcompatibility/php-compatibility": "^9.3.5",
+                "roave/security-advisories": "dev-latest",
+                "squizlabs/php_codesniffer": "^3.5.6",
+                "yoast/phpunit-polyfills": "^0.2.0"
             },
             "suggest": {
                 "ext-mbstring": "Needed to send email in multibyte encoding charset",
@@ -1068,7 +1203,17 @@
                 }
             ],
             "description": "PHPMailer is a full-featured email creation and transfer class for PHP",
-            "time": "2019-12-10T11:17:38+00:00"
+            "support": {
+                "issues": "https://github.com/PHPMailer/PHPMailer/issues",
+                "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.2.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/Synchro",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-11-25T15:24:57+00:00"
         },
         {
             "name": "pragmarx/google2fa",
@@ -1485,26 +1630,26 @@
         },
         {
             "name": "pusher/pusher-php-server",
-            "version": "v4.1.1",
+            "version": "v4.1.5",
             "source": {
                 "type": "git",
                 "url": "https://github.com/pusher/pusher-http-php.git",
-                "reference": "3c05ef64839845b6114396ff8406712cba052750"
+                "reference": "251f22602320c1b1aff84798fe74f3f7ee0504a9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/3c05ef64839845b6114396ff8406712cba052750",
-                "reference": "3c05ef64839845b6114396ff8406712cba052750",
+                "url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/251f22602320c1b1aff84798fe74f3f7ee0504a9",
+                "reference": "251f22602320c1b1aff84798fe74f3f7ee0504a9",
                 "shasum": ""
             },
             "require": {
                 "ext-curl": "*",
                 "paragonie/sodium_compat": "^1.6",
-                "php": "^7.1",
+                "php": "^7.1|^8.0",
                 "psr/log": "^1.0"
             },
             "require-dev": {
-                "phpunit/phpunit": "^7.2"
+                "phpunit/phpunit": "^7.2|^8.5|^9.3"
             },
             "type": "library",
             "extra": {
@@ -1535,7 +1680,11 @@
                 "rest",
                 "trigger"
             ],
-            "time": "2019-12-03T13:29:13+00:00"
+            "support": {
+                "issues": "https://github.com/pusher/pusher-http-php/issues",
+                "source": "https://github.com/pusher/pusher-http-php/tree/v4.1.5"
+            },
+            "time": "2020-12-09T09:38:19+00:00"
         },
         {
             "name": "ralouphie/getallheaders",
@@ -1628,36 +1777,39 @@
         },
         {
             "name": "slim/psr7",
-            "version": "1.1.0",
+            "version": "1.3.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/slimphp/Slim-Psr7.git",
-                "reference": "3c76899e707910779f13d7af95fde12310b0a5ae"
+                "reference": "235d2e5a5ee1ad4b97b96870f37f3091b22fffd7"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/slimphp/Slim-Psr7/zipball/3c76899e707910779f13d7af95fde12310b0a5ae",
-                "reference": "3c76899e707910779f13d7af95fde12310b0a5ae",
+                "url": "https://api.github.com/repos/slimphp/Slim-Psr7/zipball/235d2e5a5ee1ad4b97b96870f37f3091b22fffd7",
+                "reference": "235d2e5a5ee1ad4b97b96870f37f3091b22fffd7",
                 "shasum": ""
             },
             "require": {
-                "fig/http-message-util": "^1.1.2",
-                "php": "^7.2",
+                "fig/http-message-util": "^1.1.4",
+                "php": "^7.2 || ^8.0",
                 "psr/http-factory": "^1.0",
                 "psr/http-message": "^1.0",
-                "ralouphie/getallheaders": "^3"
+                "ralouphie/getallheaders": "^3",
+                "symfony/polyfill-php80": "^1.18"
             },
             "provide": {
+                "psr/http-factory-implementation": "1.0",
                 "psr/http-message-implementation": "1.0"
             },
             "require-dev": {
-                "adriansuter/php-autoload-override": "^1.0",
+                "adriansuter/php-autoload-override": "^1.2",
                 "ext-json": "*",
-                "http-interop/http-factory-tests": "^0.6.0",
+                "http-interop/http-factory-tests": "^0.7.0",
                 "php-http/psr7-integration-tests": "dev-master",
                 "phpstan/phpstan": "^0.12",
-                "phpunit/phpunit": "^8.5",
-                "squizlabs/php_codesniffer": "^3.5"
+                "phpunit/phpunit": "^8.5 || ^9.3",
+                "squizlabs/php_codesniffer": "^3.5",
+                "weirdan/prophecy-shim": "^1.0 || ^2.0.2"
             },
             "type": "library",
             "autoload": {
@@ -1698,54 +1850,63 @@
                 "psr-7",
                 "psr7"
             ],
-            "time": "2020-05-01T14:24:20+00:00"
+            "support": {
+                "issues": "https://github.com/slimphp/Slim-Psr7/issues",
+                "source": "https://github.com/slimphp/Slim-Psr7/tree/1.3.0"
+            },
+            "time": "2020-11-28T06:28:46+00:00"
         },
         {
             "name": "slim/slim",
-            "version": "4.0.0",
+            "version": "4.7.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/slimphp/Slim.git",
-                "reference": "2b0ed80b2ab4acfb5e7648797b8202e4d9aea06d"
+                "reference": "0905e0775f8c1cfb3bbcfabeb6588dcfd8b82d3f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/slimphp/Slim/zipball/2b0ed80b2ab4acfb5e7648797b8202e4d9aea06d",
-                "reference": "2b0ed80b2ab4acfb5e7648797b8202e4d9aea06d",
+                "url": "https://api.github.com/repos/slimphp/Slim/zipball/0905e0775f8c1cfb3bbcfabeb6588dcfd8b82d3f",
+                "reference": "0905e0775f8c1cfb3bbcfabeb6588dcfd8b82d3f",
                 "shasum": ""
             },
             "require": {
                 "ext-json": "*",
                 "nikic/fast-route": "^1.3",
-                "php": "^7.1",
+                "php": "^7.2 || ^8.0",
                 "psr/container": "^1.0",
                 "psr/http-factory": "^1.0",
                 "psr/http-message": "^1.0",
                 "psr/http-server-handler": "^1.0",
-                "psr/http-server-middleware": "^1.0"
+                "psr/http-server-middleware": "^1.0",
+                "psr/log": "^1.1"
             },
             "require-dev": {
+                "adriansuter/php-autoload-override": "^1.2",
                 "ext-simplexml": "*",
-                "guzzlehttp/psr7": "^1.5",
+                "guzzlehttp/psr7": "^1.7",
                 "http-interop/http-factory-guzzle": "^1.0",
-                "nyholm/psr7": "^1.1",
-                "nyholm/psr7-server": "^0.3.0",
-                "phpspec/prophecy": "^1.8",
-                "phpstan/phpstan": "^0.11.5",
-                "phpunit/phpunit": "^7.5",
-                "slim/http": "^0.7",
-                "slim/psr7": "^0.3",
-                "squizlabs/php_codesniffer": "^3.4.2",
-                "zendframework/zend-diactoros": "^2.1"
+                "laminas/laminas-diactoros": "^2.4",
+                "nyholm/psr7": "^1.3",
+                "nyholm/psr7-server": "^1.0.1",
+                "phpspec/prophecy": "^1.12",
+                "phpstan/phpstan": "^0.12.58",
+                "phpunit/phpunit": "^8.5.13",
+                "slim/http": "^1.2",
+                "slim/psr7": "^1.3",
+                "squizlabs/php_codesniffer": "^3.5",
+                "weirdan/prophecy-shim": "^1.0 || ^2.0.2"
             },
             "suggest": {
-                "slim/psr7": "Slim PSR-7 implementation. See http://www.slimframework.com/docs/v4/start/installation.html for more information."
+                "ext-simplexml": "Needed to support XML format in BodyParsingMiddleware",
+                "ext-xml": "Needed to support XML format in BodyParsingMiddleware",
+                "php-di/php-di": "PHP-DI is the recommended container library to be used with Slim",
+                "slim/psr7": "Slim PSR-7 implementation. See https://www.slimframework.com/docs/v4/start/installation.html for more information."
             },
             "type": "library",
             "autoload": {
                 "psr-4": {
-                    "Slim\\": "Slim",
-                    "Slim\\Tests\\": "tests"
+                    "Slim\\": "Slim"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
@@ -1780,14 +1941,34 @@
                 }
             ],
             "description": "Slim is a PHP micro framework that helps you quickly write simple yet powerful web applications and APIs",
-            "homepage": "https://slimframework.com",
+            "homepage": "https://www.slimframework.com",
             "keywords": [
                 "api",
                 "framework",
                 "micro",
                 "router"
             ],
-            "time": "2019-08-01T16:11:29+00:00"
+            "support": {
+                "docs": "https://www.slimframework.com/docs/v4/",
+                "forum": "https://discourse.slimframework.com/",
+                "irc": "irc://irc.freenode.net:6667/slimphp",
+                "issues": "https://github.com/slimphp/Slim/issues",
+                "rss": "https://www.slimframework.com/blog/feed.rss",
+                "slack": "https://slimphp.slack.com/",
+                "source": "https://github.com/slimphp/Slim",
+                "wiki": "https://github.com/slimphp/Slim/wiki"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/slimphp",
+                    "type": "open_collective"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/slim/slim",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2020-12-01T19:41:22+00:00"
         },
         {
             "name": "symfony/deprecation-contracts",
@@ -2120,6 +2301,89 @@
             ],
             "time": "2018-09-21T13:07:52+00:00"
         },
+        {
+            "name": "symfony/polyfill-php80",
+            "version": "v1.22.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-php80.git",
+                "reference": "dc3063ba22c2a1fd2f45ed856374d79114998f91"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dc3063ba22c2a1fd2f45ed856374d79114998f91",
+                "reference": "dc3063ba22c2a1fd2f45ed856374d79114998f91",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.1"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "1.22-dev"
+                },
+                "thanks": {
+                    "name": "symfony/polyfill",
+                    "url": "https://github.com/symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Polyfill\\Php80\\": ""
+                },
+                "files": [
+                    "bootstrap.php"
+                ],
+                "classmap": [
+                    "Resources/stubs"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Ion Bazan",
+                    "email": "ion.bazan@gmail.com"
+                },
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-php80/tree/v1.22.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-01-07T16:49:33+00:00"
+        },
         {
             "name": "symfony/polyfill-util",
             "version": "v1.9.0",
@@ -2432,5 +2696,6 @@
     "prefer-stable": false,
     "prefer-lowest": false,
     "platform": [],
-    "platform-dev": []
+    "platform-dev": [],
+    "plugin-api-version": "2.0.0"
 }

+ 46 - 7
api/config/default.php

@@ -13,8 +13,8 @@ return array(
 	'authBaseDN' => '',
 	'authBackendDomain' => '',
 	'ldapType' => '1',
-	'logo' => 'plugins/images/organizr/logo-wide.png',
-	'loginLogo' => 'plugins/images/organizr/logo-wide.png',
+	'logo' => 'plugins/images/organizr/organizr-logo-h.png',
+	'loginLogo' => 'plugins/images/organizr/organizr-logo-h.png',
 	'loginWallpaper' => '',
 	'title' => 'Organizr V2',
 	'useLogo' => false,
@@ -46,14 +46,23 @@ return array(
 	'jellyfinTabName' => '',
 	'jellyfinURL' => '',
 	'jellyfinToken' => '',
+	'jellyfinSSOURL' => '',
 	'plexID' => '',
 	'tautulliURL' => '',
 	'ombiURL' => '',
 	'ombiToken' => '',
 	'ombiAlias' => false,
+	'ombiFallbackUser' => '',
+	'ombiFallbackPassword' => '',
+	'overseerrURL' => '',
+	'overseerrToken' => '',
+	'overseerrFallbackUser' => '',
+	'overseerrFallbackPassword' => '',
 	'ssoPlex' => false,
 	'ssoOmbi' => false,
 	'ssoTautulli' => false,
+	'ssoJellyfin' => false,
+	'ssoOverseerr' => false,
 	'sonarrURL' => '',
 	'sonarrUnmonitored' => false,
 	'sonarrToken' => '',
@@ -64,6 +73,10 @@ return array(
 	'lidarrSocksEnabled' => false,
 	'lidarrSocksAuth' => '999',
 	'radarrURL' => '',
+	'radarrUnmonitored' => false,
+	'radarrPhysicalRelease' => true,
+	'radarrDigitalRelease' => false,
+	'radarrCinemaRelease' => false,
 	'radarrToken' => '',
 	'radarrSocksEnabled' => false,
 	'radarrSocksAuth' => '999',
@@ -86,6 +99,7 @@ return array(
 	'transmissionHideSeeding' => false,
 	'transmissionHideCompleted' => false,
 	'transmissionCombine' => false,
+	'transmissionDisableCertCheck' => false,
 	'delugeURL' => '',
 	'delugePassword' => '',
 	'delugeHideSeeding' => false,
@@ -100,6 +114,7 @@ return array(
 	'qBittorrentReverseSorting' => false,
 	'qBittorrentCombine' => false,
 	'qBittorrentApiVersion' => '1',
+	'qBittorrentDisableCertCheck' => false,
 	'rTorrentURL' => '',
 	'rTorrentURLOverride' => '',
 	'rTorrentUsername' => '',
@@ -111,6 +126,10 @@ return array(
 	'rTorrentCombine' => false,
 	'rTorrentDisableCertCheck' => false,
 	'rTorrentLimit' => '200',
+	'homepageJackettEnabled' => false,
+	'homepageJackettAuth' => '1',
+	'jackettURL' => '',
+	'jackettToken' => '',
 	'homepageCalendarEnabled' => false,
 	'homepageCalendarAuth' => '4',
 	'calendariCal' => '',
@@ -208,14 +227,18 @@ return array(
 	'homepageOrderOctoprint' => '28',
 	'homepageOrderjellyfinnowplaying' => '29',
 	'homepageOrderjellyfinrecent' => '30',
+	'homepageOrderJackett' => '31',
 	'homepageShowStreamNames' => false,
 	'homepageShowStreamNamesAuth' => '1',
+	'homepageUseCustomStreamNames' => false,
+	'homepageCustomStreamNames' => '',
 	'homepageStreamRefresh' => '60000',
 	'homepageRecentRefresh' => '60000',
 	'homepageDownloadRefresh' => '60000',
 	'homepageHealthChecksRefresh' => '60000',
 	'homepagePlexStreams' => false,
 	'homepagePlexStreamsAuth' => '1',
+	'homepagePlexStreamsExclude' => '',
 	'homepagePlexRecent' => false,
 	'homepageRecentLimit' => '50',
 	'homepagePlexRecentAuth' => '1',
@@ -225,10 +248,12 @@ return array(
 	'homepageEmbyStreamsAuth' => '1',
 	'homepageEmbyRecent' => false,
 	'homepageEmbyRecentAuth' => '1',
+	'homepageEmbyLink' => ' http://app.emby.media/#!/item/item.html?id={id}&serverId={serverId}',
 	'homepageJellyfinStreams' => false,
 	'homepageJellyStreamsAuth' => '1',
 	'homepageJellyfinRecent' => false,
 	'homepageJellyfinRecentAuth' => '1',
+	'homepageJellyfinLink' => 'http://hostname:port/jellyfin/web/index.html#!/details?id={id}&serverId={serverId}',
 	'calendarDefault' => 'month',
 	'calendarFirstDay' => '1',
 	'calendarStart' => '14',
@@ -330,6 +355,7 @@ return array(
 	'tautulliPopularTV' => true,
 	'tautulliHeader' => 'Tautulli',
 	'tautulliHeaderToggle' => true,
+	'tautulliFriendlyName' => true,
 	'homepagePiholeEnabled' => false,
 	'homepagePiholeAuth' => '1',
 	'homepagePiholeRefresh' => '10000',
@@ -416,9 +442,7 @@ return array(
 	'netdata5Enabled' => false,
 	'netdata6Enabled' => false,
 	'netdata7Enabled' => false,
-	'netdataCustom' => '{
-
-	}',
+	'netdataCustom' => '{}',
 	'homepageOctoprintEnabled' => false,
 	'homepageOctoprintAuth' => '1',
 	'homepageOctoprintRefresh' => 10000,
@@ -429,5 +453,20 @@ return array(
 	'githubMenuLink' => true,
 	'organizrSupportMenuLink' => true,
 	'organizrDocsMenuLink' => true,
-	'organizrSignoutMenuLink' => true
-);
+	'organizrSignoutMenuLink' => true,
+	'organizrFeatureRequestLink' => true,
+	'breezometerToken' => 'd95ab607392d4fa5bf64bb26a5cb2a06',
+	'customForgotPassText' => '',
+	'disableRecoverPass' => false,
+	'expandCategoriesByDefault' => false,
+	'ignoredNewsIds' => array(),
+	'homepageTraktEnabled' => false,
+	'homepageTraktAuth' => '1',
+	'calendarStartTrakt' => '14',
+	'calendarEndTrakt' => '14',
+	'traktClientId' => '',
+	'traktClientSecret' => '',
+	'traktAccessToken' => '',
+	'traktAccessTokenExpires' => '',
+	'traktRefreshToken' => ''
+);

+ 164 - 6
api/functions/auth-functions.php

@@ -2,9 +2,167 @@
 
 trait AuthFunctions
 {
-	public function testing()
+	public function testConnectionLdap()
 	{
-		return 'wasssup';
+		if (!empty($this->config['authBaseDN']) && !empty($this->config['authBackendHost'])) {
+			$ad = new \Adldap\Adldap();
+			// Create a configuration array.
+			$ldapServers = explode(',', $this->config['authBackendHost']);
+			$i = 0;
+			foreach ($ldapServers as $key => $value) {
+				// Calculate parts
+				$digest = parse_url(trim($value));
+				$scheme = strtolower((isset($digest['scheme']) ? $digest['scheme'] : 'ldap'));
+				$host = (isset($digest['host']) ? $digest['host'] : (isset($digest['path']) ? $digest['path'] : ''));
+				$port = (isset($digest['port']) ? $digest['port'] : (strtolower($scheme) == 'ldap' ? 389 : 636));
+				// Reassign
+				$ldapHosts[] = $host;
+				if ($i == 0) {
+					$ldapPort = $port;
+				}
+				$i++;
+			}
+			$config = [
+				// Mandatory Configuration Options
+				'hosts' => $ldapHosts,
+				'base_dn' => $this->config['authBaseDN'],
+				'username' => (empty($this->config['ldapBindUsername'])) ? null : $this->config['ldapBindUsername'],
+				'password' => (empty($this->config['ldapBindPassword'])) ? null : $this->decrypt($this->config['ldapBindPassword']),
+				// Optional Configuration Options
+				'schema' => (($this->config['ldapType'] == '1') ? Adldap\Schemas\ActiveDirectory::class : (($this->config['ldapType'] == '2') ? Adldap\Schemas\OpenLDAP::class : Adldap\Schemas\FreeIPA::class)),
+				'account_prefix' => '',
+				'account_suffix' => '',
+				'port' => $ldapPort,
+				'follow_referrals' => false,
+				'use_ssl' => $this->config['ldapSSL'],
+				'use_tls' => $this->config['ldapTLS'],
+				'version' => 3,
+				'timeout' => 5,
+				// Custom LDAP Options
+				'custom_options' => [
+					// See: http://php.net/ldap_set_option
+					//LDAP_OPT_X_TLS_REQUIRE_CERT => LDAP_OPT_X_TLS_HARD
+				]
+			];
+			// Add a connection provider to Adldap.
+			$ad->addProvider($config);
+			try {
+				// If a successful connection is made to your server, the provider will be returned.
+				$provider = $ad->connect();
+			} catch (\Adldap\Auth\BindException $e) {
+				$detailedError = $e->getDetailedError();
+				$this->writeLog('error', 'LDAP Function - Error: ' . $detailedError->getErrorMessage(), 'SYSTEM');
+				$this->setAPIResponse('error', $detailedError->getErrorMessage(), 409);
+				return $detailedError->getErrorMessage();
+				// There was an issue binding / connecting to the server.
+			}
+			if ($provider) {
+				$this->setAPIResponse('success', 'LDAP connection successful', 200);
+				return true;
+			} else {
+				$this->setAPIResponse('error', 'Could not connect', 500);
+				return false;
+			}
+			return ($provider) ? true : false;
+		} else {
+			$this->setAPIResponse('error', 'authBaseDN and/or BackendHost not supplied', 422);
+			return false;
+		}
+	}
+	
+	public function testConnectionLdapLogin($array)
+	{
+		$username = $array['username'] ?? null;
+		$password = $array['password'] ?? null;
+		if (empty($username) || empty($password)) {
+			$this->setAPIResponse('error', 'Username and/or Password not supplied', 422);
+			return false;
+		}
+		if (!empty($this->config['authBaseDN']) && !empty($this->config['authBackendHost'])) {
+			$ad = new \Adldap\Adldap();
+			// Create a configuration array.
+			$ldapServers = explode(',', $this->config['authBackendHost']);
+			$i = 0;
+			foreach ($ldapServers as $key => $value) {
+				// Calculate parts
+				$digest = parse_url(trim($value));
+				$scheme = strtolower((isset($digest['scheme']) ? $digest['scheme'] : 'ldap'));
+				$host = (isset($digest['host']) ? $digest['host'] : (isset($digest['path']) ? $digest['path'] : ''));
+				$port = (isset($digest['port']) ? $digest['port'] : (strtolower($scheme) == 'ldap' ? 389 : 636));
+				// Reassign
+				$ldapHosts[] = $host;
+				$ldapServersNew[$key] = $scheme . '://' . $host . ':' . $port; // May use this later
+				if ($i == 0) {
+					$ldapPort = $port;
+				}
+				$i++;
+			}
+			$config = [
+				// Mandatory Configuration Options
+				'hosts' => $ldapHosts,
+				'base_dn' => $this->config['authBaseDN'],
+				'username' => (empty($this->config['ldapBindUsername'])) ? null : $this->config['ldapBindUsername'],
+				'password' => (empty($this->config['ldapBindPassword'])) ? null : $this->decrypt($this->config['ldapBindPassword']),
+				// Optional Configuration Options
+				'schema' => (($this->config['ldapType'] == '1') ? Adldap\Schemas\ActiveDirectory::class : (($this->config['ldapType'] == '2') ? Adldap\Schemas\OpenLDAP::class : Adldap\Schemas\FreeIPA::class)),
+				'account_prefix' => (empty($this->config['authBackendHostPrefix'])) ? null : $this->config['authBackendHostPrefix'],
+				'account_suffix' => (empty($this->config['authBackendHostSuffix'])) ? null : $this->config['authBackendHostSuffix'],
+				'port' => $ldapPort,
+				'follow_referrals' => false,
+				'use_ssl' => $this->config['ldapSSL'],
+				'use_tls' => $this->config['ldapTLS'],
+				'version' => 3,
+				'timeout' => 5,
+				// Custom LDAP Options
+				'custom_options' => [
+					// See: http://php.net/ldap_set_option
+					//LDAP_OPT_X_TLS_REQUIRE_CERT => LDAP_OPT_X_TLS_HARD
+				]
+			];
+			// Add a connection provider to Adldap.
+			$ad->addProvider($config);
+			try {
+				// If a successful connection is made to your server, the provider will be returned.
+				$provider = $ad->connect();
+				//prettyPrint($provider);
+				if ($provider->auth()->attempt($username, $password, true)) {
+					// Passed.
+					$user = $provider->search()->find($username);
+					//return $user->getFirstAttribute('cn');
+					//return $user->getGroups(['cn']);
+					//return $user;
+					//return $user->getUserPrincipalName();
+					//return $user->getGroups(['cn']);
+					$this->setAPIResponse('success', 'LDAP connection successful', 200);
+					return true;
+				} else {
+					// Failed.
+					$this->setAPIResponse('error', 'Username/Password Failed to authenticate', 401);
+					return false;
+				}
+			} catch (\Adldap\Auth\BindException $e) {
+				$detailedError = $e->getDetailedError();
+				$this->writeLog('error', 'LDAP Function - Error: ' . $detailedError->getErrorMessage(), $username);
+				$this->setAPIResponse('error', $detailedError->getErrorMessage(), 500);
+				return $detailedError->getErrorMessage();
+				// There was an issue binding / connecting to the server.
+			} catch (Adldap\Auth\UsernameRequiredException $e) {
+				$detailedError = $e->getDetailedError();
+				$this->writeLog('error', 'LDAP Function - Error: ' . $detailedError->getErrorMessage(), $username);
+				$this->setAPIResponse('error', $detailedError->getErrorMessage(), 422);
+				return $detailedError->getErrorMessage();
+				// The user didn't supply a username.
+			} catch (Adldap\Auth\PasswordRequiredException $e) {
+				$detailedError = $e->getDetailedError();
+				$this->writeLog('error', 'LDAP Function - Error: ' . $detailedError->getErrorMessage(), $username);
+				$this->setAPIResponse('error', $detailedError->getErrorMessage(), 422);
+				return $detailedError->getErrorMessage();
+				// The user didn't supply a password.
+			}
+		} else {
+			$this->setAPIResponse('error', 'authBaseDN and/or BackendHost not supplied', 422);
+			return false;
+		}
 	}
 	
 	public function checkPlexToken($token = '')
@@ -26,7 +184,7 @@ trait AuthFunctions
 			}
 			
 		} catch (Requests_Exception $e) {
-			$this->writeLog('success', 'Plex Token Check Function - Error: ' . $e->getMessage(), SYSTEM);
+			$this->writeLog('success', 'Plex Token Check Function - Error: ' . $e->getMessage(), 'SYSTEM');
 		}
 		return false;
 	}
@@ -144,7 +302,7 @@ trait AuthFunctions
 				'username' => (empty($this->config['ldapBindUsername'])) ? null : $this->config['ldapBindUsername'],
 				'password' => (empty($this->config['ldapBindPassword'])) ? null : $this->decrypt($this->config['ldapBindPassword']),
 				// Optional Configuration Options
-				'schema' => (($GLOBALS['ldapType'] == '1') ? Adldap\Schemas\ActiveDirectory::class : (($GLOBALS['ldapType'] == '2') ? Adldap\Schemas\OpenLDAP::class : Adldap\Schemas\FreeIPA::class)),
+				'schema' => (($this->config['ldapType'] == '1') ? Adldap\Schemas\ActiveDirectory::class : (($this->config['ldapType'] == '2') ? Adldap\Schemas\OpenLDAP::class : Adldap\Schemas\FreeIPA::class)),
 				'account_prefix' => (empty($this->config['authBackendHostPrefix'])) ? null : $this->config['authBackendHostPrefix'],
 				'account_suffix' => (empty($this->config['authBackendHostSuffix'])) ? null : $this->config['authBackendHostSuffix'],
 				'port' => $ldapPort,
@@ -276,7 +434,7 @@ trait AuthFunctions
 	public function plugin_auth_jellyfin($username, $password)
 	{
 		try {
-			$url = $this->qualifyURL($this->config['embyURL']) . '/Users/authenticatebyname';
+			$url = $this->qualifyURL($this->config['jellyfinURL']) . '/Users/authenticatebyname';
 			$headers = array(
 				'X-Emby-Authorization' => 'MediaBrowser Client="Organizr Auth", Device="Organizr", DeviceId="orgv2", Version="2.0"',
 				'Content-Type' => 'application/json',
@@ -295,7 +453,7 @@ trait AuthFunctions
 						'X-Emby-Authorization' => 'MediaBrowser Client="Organizr Auth", Device="Organizr", DeviceId="orgv2", Version="2.0", Token="' . $json['AccessToken'] . '"',
 						'Content-Type' => 'application/json',
 					);
-					$response = Requests::post($this->qualifyURL($this->config['embyURL']) . '/Sessions/Logout', $headers, array());
+					$response = Requests::post($this->qualifyURL($this->config['jellyfinURL']) . '/Sessions/Logout', $headers, array());
 					if ($response->success) {
 						return true;
 					}

+ 64 - 2
api/functions/backup-functions.php

@@ -15,7 +15,44 @@ trait BackupFunctions
 		}
 	}
 	
-	public function backupDB($type = 'config')
+	public function deleteBackup($filename)
+	{
+		$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
+		$path = $this->config['dbLocation'] . 'backups' . DIRECTORY_SEPARATOR;
+		$filename = $path . $filename;
+		if ($ext == 'zip') {
+			if (file_exists($filename)) {
+				$this->writeLog('success', 'Backup Manager Function -  Deleted Backup [' . pathinfo($filename, PATHINFO_BASENAME) . ']', $this->user['username']);
+				$this->setAPIResponse(null, pathinfo($filename, PATHINFO_BASENAME) . ' has been deleted', null);
+				return (unlink($filename));
+			} else {
+				$this->setAPIResponse('error', 'File does not exist', 404);
+				return false;
+			}
+		} else {
+			$this->setAPIResponse('error', pathinfo($filename, PATHINFO_BASENAME) . ' is not approved to be deleted', 409);
+			return false;
+		}
+	}
+	
+	public function downloadBackup($filename)
+	{
+		$path = $this->config['dbLocation'] . 'backups' . DIRECTORY_SEPARATOR;
+		$filename = $path . $filename;
+		if (file_exists($filename)) {
+			header('Content-Type: application/zip');
+			header('Content-Disposition: attachment; filename="' . basename($filename) . '"');
+			header('Content-Length: ' . filesize($filename));
+			flush();
+			readfile($filename);
+			exit();
+		} else {
+			$this->setAPIResponse('error', 'File does not exist', 404);
+			return false;
+		}
+	}
+	
+	public function backupOrganizr($type = 'config')
 	{
 		$directory = $this->config['dbLocation'] . 'backups' . DIRECTORY_SEPARATOR;
 		@mkdir($directory, 0770, true);
@@ -44,8 +81,10 @@ trait BackupFunctions
 			}
 			$zip->close();
 			$this->writeLog('success', 'BACKUP: backup process finished', 'SYSTEM');
+			$this->setAPIResponse('success', 'Backup has been created', 200);
 			return true;
 		} else {
+			$this->setAPIResponse('error', 'Backup creation failed', 409);
 			return false;
 		}
 		
@@ -56,7 +95,30 @@ trait BackupFunctions
 		$path = $this->config['dbLocation'] . 'backups' . DIRECTORY_SEPARATOR;
 		@mkdir($path, 0770, true);
 		$files = array_diff(scandir($path), array('.', '..'));
-		return array_reverse($files);
+		$fileList = [];
+		$totalFiles = 0;
+		$totalFileSize = 0;
+		foreach ($files as $file) {
+			if (file_exists($path . $file)) {
+				$size = filesize($path . $file);
+				$totalFileSize = $totalFileSize + $size;
+				$totalFiles = $totalFiles + 1;
+				try {
+					$fileList['files'][] = [
+						'name' => $file,
+						'size' => $this->human_filesize($size, 0),
+						'date' => gmdate("Y-m-d\TH:i:s\Z", (filemtime($path . $file)))
+					];
+				} catch (Exception $e) {
+					$this->setAPIResponse('error', 'Backup list failed', 409, $e->getMessage());
+					return false;
+				}
+			}
+		}
+		$fileList['total_files'] = $totalFiles;
+		$fileList['total_size'] = $this->human_filesize($totalFileSize, 2);
+		$this->setAPIResponse('success', null, 200, array_reverse($fileList));
+		return array_reverse($fileList);
 	}
 	
 }

+ 1 - 151
api/functions/homepage-connect-functions.php

@@ -90,154 +90,4 @@ trait HomepageConnectFunctions
 		}
 		return false;
 	}
-}
-
-
-function testAPIConnection($array)
-{
-	switch ($array['data']['action']) {
-		case 'ldap_login':
-			$username = $array['data']['data']['username'];
-			$password = $array['data']['data']['password'];
-			if (empty($username) || empty($password)) {
-				return 'Missing Username or Password';
-			}
-			if (!empty($GLOBALS['authBaseDN']) && !empty($GLOBALS['authBackendHost'])) {
-				$ad = new \Adldap\Adldap();
-				// Create a configuration array.
-				$ldapServers = explode(',', $GLOBALS['authBackendHost']);
-				$i = 0;
-				foreach ($ldapServers as $key => $value) {
-					// Calculate parts
-					$digest = parse_url(trim($value));
-					$scheme = strtolower((isset($digest['scheme']) ? $digest['scheme'] : 'ldap'));
-					$host = (isset($digest['host']) ? $digest['host'] : (isset($digest['path']) ? $digest['path'] : ''));
-					$port = (isset($digest['port']) ? $digest['port'] : (strtolower($scheme) == 'ldap' ? 389 : 636));
-					// Reassign
-					$ldapHosts[] = $host;
-					$ldapServersNew[$key] = $scheme . '://' . $host . ':' . $port; // May use this later
-					if ($i == 0) {
-						$ldapPort = $port;
-					}
-					$i++;
-				}
-				$config = [
-					// Mandatory Configuration Options
-					'hosts' => $ldapHosts,
-					'base_dn' => $GLOBALS['authBaseDN'],
-					'username' => (empty($GLOBALS['ldapBindUsername'])) ? null : $GLOBALS['ldapBindUsername'],
-					'password' => (empty($GLOBALS['ldapBindPassword'])) ? null : decrypt($GLOBALS['ldapBindPassword']),
-					// Optional Configuration Options
-					'schema' => (($GLOBALS['ldapType'] == '1') ? Adldap\Schemas\ActiveDirectory::class : (($GLOBALS['ldapType'] == '2') ? Adldap\Schemas\OpenLDAP::class : Adldap\Schemas\FreeIPA::class)),
-					'account_prefix' => (empty($GLOBALS['authBackendHostPrefix'])) ? null : $GLOBALS['authBackendHostPrefix'],
-					'account_suffix' => (empty($GLOBALS['authBackendHostSuffix'])) ? null : $GLOBALS['authBackendHostSuffix'],
-					'port' => $ldapPort,
-					'follow_referrals' => false,
-					'use_ssl' => $GLOBALS['ldapSSL'],
-					'use_tls' => $GLOBALS['ldapTLS'],
-					'version' => 3,
-					'timeout' => 5,
-					// Custom LDAP Options
-					'custom_options' => [
-						// See: http://php.net/ldap_set_option
-						//LDAP_OPT_X_TLS_REQUIRE_CERT => LDAP_OPT_X_TLS_HARD
-					]
-				];
-				// Add a connection provider to Adldap.
-				$ad->addProvider($config);
-				try {
-					// If a successful connection is made to your server, the provider will be returned.
-					$provider = $ad->connect();
-					//prettyPrint($provider);
-					if ($provider->auth()->attempt($username, $password, true)) {
-						// Passed.
-						$user = $provider->search()->find($username);
-						//return $user->getFirstAttribute('cn');
-						//return $user->getGroups(['cn']);
-						//return $user;
-						//return $user->getUserPrincipalName();
-						//return $user->getGroups(['cn']);
-						return true;
-					} else {
-						// Failed.
-						return 'Username/Password Failed to authenticate';
-					}
-				} catch (\Adldap\Auth\BindException $e) {
-					$detailedError = $e->getDetailedError();
-					writeLog('error', 'LDAP Function - Error: ' . $detailedError->getErrorMessage(), $username);
-					return $detailedError->getErrorMessage();
-					// There was an issue binding / connecting to the server.
-				} catch (Adldap\Auth\UsernameRequiredException $e) {
-					$detailedError = $e->getDetailedError();
-					writeLog('error', 'LDAP Function - Error: ' . $detailedError->getErrorMessage(), $username);
-					return $detailedError->getErrorMessage();
-					// The user didn't supply a username.
-				} catch (Adldap\Auth\PasswordRequiredException $e) {
-					$detailedError = $e->getDetailedError();
-					writeLog('error', 'LDAP Function - Error: ' . $detailedError->getErrorMessage(), $username);
-					return $detailedError->getErrorMessage();
-					// The user didn't supply a password.
-				}
-			}
-			break;
-		case 'ldap':
-			if (!empty($GLOBALS['authBaseDN']) && !empty($GLOBALS['authBackendHost'])) {
-				$ad = new \Adldap\Adldap();
-				// Create a configuration array.
-				$ldapServers = explode(',', $GLOBALS['authBackendHost']);
-				$i = 0;
-				foreach ($ldapServers as $key => $value) {
-					// Calculate parts
-					$digest = parse_url(trim($value));
-					$scheme = strtolower((isset($digest['scheme']) ? $digest['scheme'] : 'ldap'));
-					$host = (isset($digest['host']) ? $digest['host'] : (isset($digest['path']) ? $digest['path'] : ''));
-					$port = (isset($digest['port']) ? $digest['port'] : (strtolower($scheme) == 'ldap' ? 389 : 636));
-					// Reassign
-					$ldapHosts[] = $host;
-					if ($i == 0) {
-						$ldapPort = $port;
-					}
-					$i++;
-				}
-				$config = [
-					// Mandatory Configuration Options
-					'hosts' => $ldapHosts,
-					'base_dn' => $GLOBALS['authBaseDN'],
-					'username' => (empty($GLOBALS['ldapBindUsername'])) ? null : $GLOBALS['ldapBindUsername'],
-					'password' => (empty($GLOBALS['ldapBindPassword'])) ? null : decrypt($GLOBALS['ldapBindPassword']),
-					// Optional Configuration Options
-					'schema' => (($GLOBALS['ldapType'] == '1') ? Adldap\Schemas\ActiveDirectory::class : (($GLOBALS['ldapType'] == '2') ? Adldap\Schemas\OpenLDAP::class : Adldap\Schemas\FreeIPA::class)),
-					'account_prefix' => '',
-					'account_suffix' => '',
-					'port' => $ldapPort,
-					'follow_referrals' => false,
-					'use_ssl' => $GLOBALS['ldapSSL'],
-					'use_tls' => $GLOBALS['ldapTLS'],
-					'version' => 3,
-					'timeout' => 5,
-					// Custom LDAP Options
-					'custom_options' => [
-						// See: http://php.net/ldap_set_option
-						//LDAP_OPT_X_TLS_REQUIRE_CERT => LDAP_OPT_X_TLS_HARD
-					]
-				];
-				// Add a connection provider to Adldap.
-				$ad->addProvider($config);
-				try {
-					// If a successful connection is made to your server, the provider will be returned.
-					$provider = $ad->connect();
-				} catch (\Adldap\Auth\BindException $e) {
-					$detailedError = $e->getDetailedError();
-					writeLog('error', 'LDAP Function - Error: ' . $detailedError->getErrorMessage(), 'SYSTEM');
-					return $detailedError->getErrorMessage();
-					// There was an issue binding / connecting to the server.
-				}
-				return ($provider) ? true : false;
-			}
-			return false;
-			break;
-		default :
-			return false;
-	}
-	return false;
-}
+}

+ 93 - 1
api/functions/homepage-functions.php

@@ -2,5 +2,97 @@
 
 trait HomepageFunctions
 {
-
+	public function getHomepageSettingsList()
+	{
+		$methods = get_class_methods($this);
+		$searchTerm = 'SettingsArray';
+		return array_filter($methods, function ($k) use ($searchTerm) {
+			return stripos($k, $searchTerm) !== false;
+		}, 0);
+	}
+	
+	public function getHomepageSettingsCombined()
+	{
+		$list = $this->getHomepageSettingsList();
+		$combined = [];
+		foreach ($list as $item) {
+			$combined[] = $this->$item();
+		}
+		return $combined;
+	}
+	
+	public function homepageItemPermissions($settings = false, $api = false)
+	{
+		if (!$settings) {
+			if ($api) {
+				$this->setAPIResponse('error', 'No settings were supplied', 422);
+			}
+			return false;
+		}
+		foreach ($settings as $type => $setting) {
+			$settingsType = gettype($setting);
+			switch ($type) {
+				case 'enabled':
+					if ($settingsType == 'string') {
+						if (!$this->config[$setting]) {
+							if ($api) {
+								$this->setAPIResponse('error', $setting . ' module is not enabled', 409);
+							}
+							return false;
+						}
+					} else {
+						foreach ($setting as $item) {
+							if (!$this->config[$item]) {
+								if ($api) {
+									$this->setAPIResponse('error', $item . ' module is not enabled', 409);
+								}
+								return false;
+							}
+						}
+					}
+					break;
+				case 'auth':
+					if ($settingsType == 'string') {
+						if (!$this->qualifyRequest($this->config[$setting])) {
+							if ($api) {
+								$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
+							}
+							return false;
+						}
+					} else {
+						foreach ($setting as $item) {
+							if (!$this->qualifyRequest($this->config[$item])) {
+								if ($api) {
+									$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
+								}
+								return false;
+							}
+						}
+					}
+					break;
+				case 'not_empty':
+					if ($settingsType == 'string') {
+						if (empty($this->config[$setting])) {
+							if ($api) {
+								$this->setAPIResponse('error', $setting . 'was not supplied', 422);
+							}
+							return false;
+						}
+					} else {
+						foreach ($setting as $item) {
+							if (empty($this->config[$item])) {
+								if ($api) {
+									$this->setAPIResponse('error', $item . 'was not supplied', 422);
+								}
+								return false;
+							}
+						}
+					}
+					break;
+				default:
+					//return false;
+			}
+		}
+		return true;
+	}
 }

+ 20 - 6
api/functions/log-functions.php

@@ -2,9 +2,23 @@
 
 trait LogFunctions
 {
-
-}
-
-
-
-
+	public function info($msg, $username = null)
+	{
+		$this->writeLog('info', $msg, $username);
+	}
+	
+	public function error($msg, $username = null)
+	{
+		$this->writeLog('error', $msg, $username);
+	}
+	
+	public function warning($msg, $username = null)
+	{
+		$this->writeLog('warning', $msg, $username);
+	}
+	
+	public function debug($msg, $username = null)
+	{
+		$this->writeLog('debug', $msg, $username);
+	}
+}

+ 22 - 8
api/functions/normal-functions.php

@@ -266,7 +266,7 @@ trait NormalFunctions
 	
 	public function parseDomain($value, $force = false)
 	{
-		$badDomains = array('ddns.net', 'ddnsking.com', '3utilities.com', 'bounceme.net', 'duckdns.org', 'freedynamicdns.net', 'freedynamicdns.org', 'gotdns.ch', 'hopto.org', 'myddns.me', 'myds.me', 'myftp.biz', 'myftp.org', 'myvnc.com', 'noip.com', 'onthewifi.com', 'redirectme.net', 'serveblog.net', 'servecounterstrike.com', 'serveftp.com', 'servegame.com', 'servehalflife.com', 'servehttp.com', 'serveirc.com', 'serveminecraft.net', 'servemp3.com', 'servepics.com', 'servequake.com', 'sytes.net', 'viewdns.net', 'webhop.me', 'zapto.org');
+		$badDomains = array('ddns.net', 'ddnsking.com', '3utilities.com', 'bounceme.net', 'freedynamicdns.net', 'freedynamicdns.org', 'gotdns.ch', 'hopto.org', 'myddns.me', 'myds.me', 'myftp.biz', 'myftp.org', 'myvnc.com', 'noip.com', 'onthewifi.com', 'redirectme.net', 'serveblog.net', 'servecounterstrike.com', 'serveftp.com', 'servegame.com', 'servehalflife.com', 'servehttp.com', 'serveirc.com', 'serveminecraft.net', 'servemp3.com', 'servepics.com', 'servequake.com', 'sytes.net', 'viewdns.net', 'webhop.me', 'zapto.org');
 		$Domain = $value;
 		$Port = strpos($Domain, ':');
 		if ($Port !== false) {
@@ -352,7 +352,7 @@ trait NormalFunctions
 		}
 	}
 	
-	public function coookieSeconds($type, $name, $value = '', $ms, $http = true, $path = '/')
+	public function coookieSeconds($type, $name, $value = '', $ms = null, $http = true, $path = '/')
 	{
 		if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == "https") {
 			$Secure = true;
@@ -477,7 +477,9 @@ trait NormalFunctions
 				$domain = $_SERVER['HTTP_HOST'];
 			}
 		}
-		$url = $protocol . $domain . str_replace("\\", "/", dirname($_SERVER['REQUEST_URI']));
+		$path = str_replace("\\", "/", dirname($_SERVER['REQUEST_URI']));
+		$path = ($path !== '.') ? $path : '';
+		$url = $protocol . $domain . $path;
 		if (strpos($url, '/api') !== false) {
 			$url = explode('/api', $url);
 			return $url[0] . '/';
@@ -538,6 +540,23 @@ trait NormalFunctions
 		}
 		return $isLocal;
 	}
+	
+	public function human_filesize($bytes, $dec = 2)
+	{
+		$bytes = number_format($bytes, 0, '.', '');
+		$size = array('B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB');
+		$factor = floor((strlen($bytes) - 1) / 3);
+		return sprintf("%.{$dec}f %s", $bytes / (1024 ** $factor), $size[$factor]);
+	}
+	
+	public function json_validator($data = null)
+	{
+		if (!empty($data)) {
+			@json_decode($data);
+			return (json_last_error() === JSON_ERROR_NONE);
+		}
+		return false;
+	}
 }
 
 // Leave for deluge class
@@ -667,11 +686,6 @@ function download($url, $path)
 // swagger
 function getServerPath($over = false)
 {
-	if ($over) {
-		if ($GLOBALS['PHPMAILER-domain'] !== '') {
-			return $GLOBALS['PHPMAILER-domain'];
-		}
-	}
 	if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == "https") {
 		$protocol = "https://";
 	} elseif (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off') {

+ 103 - 0
api/functions/oauth.php

@@ -0,0 +1,103 @@
+<?php
+
+trait OAuthFunctions
+{
+	public function traktOAuth()
+	{
+		$provider = new Bogstag\OAuth2\Client\Provider\Trakt([
+			'clientId' => $this->config['traktClientId'],
+			'clientSecret' => $this->config['traktClientSecret'],
+			'redirectUri' => $this->getServerPath() . 'api/v2/oauth/trakt'
+		], [
+			'httpClient' => new GuzzleHttp\Client(['verify' => getCert()]),
+		]);
+		if (!isset($_GET['code'])) {
+			$authUrl = $provider->getAuthorizationUrl();
+			header('Location: ' . $authUrl);
+			exit;
+		} elseif (empty($_GET['state'])) {
+			exit('Invalid state');
+		} else {
+			try {
+				$token = $provider->getAccessToken('authorization_code', [
+					'code' => $_GET['code']
+				]);
+				$traktDetails = [
+					'traktAccessToken' => $token->getToken(),
+					'traktAccessTokenExpires' => gmdate('Y-m-d\TH:i:s\Z', $token->getExpires()),
+					'traktRefreshToken' => $token->getRefreshToken()
+				];
+				$this->updateConfig($traktDetails);
+				echo '
+					<!DOCTYPE html>
+					<html lang="en">
+					<head>
+						<link rel="stylesheet" href="' . $this->getServerPath() . '/css/mvp.css">
+						<meta charset="utf-8">
+						<meta name="description" content="Trakt OAuth">
+						<meta name="viewport" content="width=device-width, initial-scale=1.0">
+						<title>Trakt OAuth</title>
+					</head>
+					<script language=javascript>
+					function closemyself() {
+						window.opener=self;
+						window.close();
+					}
+					</script>
+					<body onLoad="setTimeout(\'closemyself()\',3000);">
+						<main>
+							<section>
+								<aside>
+									<h3>Details Saved</h3>
+									<p><sup>(This window will close automatically)</sup></p>
+								</aside>
+							</section>
+						</main>
+					</body>
+					</html>
+				';
+				exit;
+			} catch (\League\OAuth2\Client\Provider\Exception\IdentityProviderException $e) {
+				exit($e->getMessage());
+			}
+		}
+	}
+	
+	public function traktOAuthRefresh()
+	{
+		$exp = $this->config['traktAccessTokenExpires'];
+		$exp = date('Y-m-d\TH:i:s\Z', strtotime($exp . ' - 30 days'));
+		if (time() - 2592000 > strtotime($exp)) {
+			$headers = [
+				'Content-Type' => 'application/json'
+			];
+			$data = [
+				'refresh_token' => $this->config['traktRefreshToken'],
+				'clientId' => $this->config['traktClientId'],
+				'clientSecret' => $this->config['traktClientSecret'],
+				'redirectUri' => $this->getServerPath() . 'api/v2/oauth/trakt',
+				'grant_type' => 'refresh_token'
+			];
+			$url = $this->qualifyURL('https://api.trakt.tv/oauth/token');
+			try {
+				$response = Requests::post($url, $headers, json_encode($data), []);
+				if ($response->success) {
+					$data = json_decode($response->body, true);
+					$newExp = date('Y-m-d\TH:i:s\Z', strtotime($this->currentTime . ' + 90 days'));
+					$traktDetails = [
+						'traktAccessToken' => $data['access_token'],
+						'traktAccessTokenExpires' => $newExp,
+						'traktRefreshToken' => $data['refresh_token']
+					];
+					$this->updateConfig($traktDetails);
+					return true;
+				}
+			} catch (Requests_Exception $e) {
+				$this->writeLog('error', 'Trakt Connect Function - Error: ' . $e->getMessage(), 'SYSTEM');
+				$this->setAPIResponse('error', $e->getMessage(), 500);
+				return false;
+			}
+		}
+		
+	}
+}

+ 158 - 156
api/functions/organizr-functions.php

@@ -2,6 +2,145 @@
 
 trait OrganizrFunctions
 {
+	public function embyJoinAPI($array)
+	{
+		$username = ($array['username']) ?? null;
+		$email = ($array['email']) ?? null;
+		$password = ($array['password']) ?? null;
+		if (!$username) {
+			$this->setAPIResponse('error', 'Username not supplied', 422);
+			return false;
+		}
+		if (!$email) {
+			$this->setAPIResponse('error', 'Email not supplied', 422);
+			return false;
+		}
+		if (!$password) {
+			$this->setAPIResponse('error', 'Password not supplied', 422);
+			return false;
+		}
+		return $this->embyJoin($username, $email, $password);
+	}
+	
+	public function embyJoin($username, $email, $password)
+	{
+		try {
+			#create user in emby.
+			$headers = array(
+				"Accept" => "application/json"
+			);
+			$data = array();
+			$url = $this->config['embyURL'] . '/emby/Users/New?name=' . $username . '&api_key=' . $this->config['embyToken'];
+			$response = Requests::Post($url, $headers, json_encode($data), array());
+			$response = $response->body;
+			//return($response);
+			$response = json_decode($response, true);
+			//return($response);
+			$userID = $response["Id"];
+			//return($userID);
+			#authenticate as user to update password.
+			//randomizer four digits of DeviceId
+			// I dont think ther would be security problems with hardcoding deviceID but randomizing it would mitigate any issue.
+			$deviceIdSeceret = rand(0, 9) . "" . rand(0, 9) . "" . rand(0, 9) . "" . rand(0, 9);
+			//hardcoded device id with the first three digits random 0-9,0-9,0-9,0-9
+			$embyAuthHeader = 'MediaBrowser Client="Emby Mobile", Device="Firefox", DeviceId="' . $deviceIdSeceret . 'aWxssS81LgAggFdpbmRvd3MgTlQgMTAuMDsgV2luNjxx7IHf2NCkgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzcyLjAuMzYyNi4xMTkgU2FmYXJpLzUzNy4zNnwxNTUxNTczMTAyNDI4", Version="4.0.2.0"';
+			$headers = array(
+				"Accept" => "application/json",
+				"Content-Type" => "application/json",
+				"X-Emby-Authorization" => $embyAuthHeader
+			);
+			$data = array(
+				"Pw" => "",
+				"Username" => $username
+			);
+			$url = $this->config['embyURL'] . '/emby/Users/AuthenticateByName';
+			$response = Requests::Post($url, $headers, json_encode($data), array());
+			$response = $response->body;
+			$response = json_decode($response, true);
+			$userToken = $response["AccessToken"];
+			#update password
+			$embyAuthHeader = 'MediaBrowser Client="Emby Mobile", Device="Firefox", Token="' . $userToken . '", DeviceId="' . $deviceIdSeceret . 'aWxssS81LgAggFdpbmRvd3MgTlQgMTAuMDsgV2luNjxx7IHf2NCkgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzcyLjAuMzYyNi4xMTkgU2FmYXJpLzUzNy4zNnwxNTUxNTczMTAyNDI4", Version="4.0.2.0"';
+			$headers = array(
+				"Accept" => "application/json",
+				"Content-Type" => "application/json",
+				"X-Emby-Authorization" => $embyAuthHeader
+			);
+			$data = array(
+				"CurrentPw" => "",
+				"NewPw" => $password,
+				"Id" => $userID
+			);
+			$url = $this->config['embyURL'] . '/emby/Users/' . $userID . '/Password';
+			Requests::Post($url, $headers, json_encode($data), array());
+			#update config
+			$headers = array(
+				"Accept" => "application/json",
+				"Content-Type" => "application/json"
+			);
+			$url = $this->config['embyURL'] . '/emby/Users/' . $userID . '/Policy?api_key=' . $this->config['embyToken'];
+			$response = Requests::Post($url, $headers, $this->getEmbyTemplateUserJson(), array());
+			#add emby.media
+			try {
+				#seperate because this is not required
+				$headers = array(
+					"Accept" => "application/json",
+					"X-Emby-Authorization" => $embyAuthHeader
+				);
+				$data = array(
+					"ConnectUsername " => $email
+				);
+				$url = $this->config['embyURL'] . '/emby/Users/' . $userID . '/Connect/Link';
+				Requests::Post($url, $headers, json_encode($data), array());
+			} catch (Requests_Exception $e) {
+				$this->writeLog('error', 'Emby Connect Function - Error: ' . $e->getMessage(), 'SYSTEM');
+				$this->setAPIResponse('error', $e->getMessage(), 500);
+				return false;
+			}
+			$this->setAPIResponse('success', 'User has joined Emby', 200);
+			return true;
+		} catch (Requests_Exception $e) {
+			$this->writeLog('error', 'Emby create Function - Error: ' . $e->getMessage(), 'SYSTEM');
+			$this->setAPIResponse('error', $e->getMessage(), 500);
+			return false;
+		}
+	}
+	
+	/*loads users from emby and returns a correctly formated policy for a new user.
+	*/
+	public function getEmbyTemplateUserJson()
+	{
+		$headers = array(
+			"Accept" => "application/json"
+		);
+		$data = array();
+		$url = $this->config['embyURL'] . '/emby/Users?api_key=' . $this->config['embyToken'];
+		$response = Requests::Get($url, $headers, array());
+		$response = $response->body;
+		$response = json_decode($response, true);
+		//error_Log("response ".json_encode($response));
+		$this->writeLog('error', 'userList:' . json_encode($response), 'SYSTEM');
+		//$correct stores the template users object
+		$correct = null;
+		foreach ($response as $element) {
+			if ($element['Name'] == $this->config['INVITES-EmbyTemplate']) {
+				$correct = $element;
+			}
+		}
+		$this->writeLog('error', 'Correct user:' . json_encode($correct), 'SYSTEM');
+		if ($correct == null) {
+			//return empty JSON if user incorrectly configured template
+			return "{}";
+		}
+		//select policy section and remove possibly dangerous rows.
+		$policy = $correct['Policy'];
+		//writeLog('error', 'policy update'.$policy, 'SYSTEM');
+		unset($policy['AuthenticationProviderId']);
+		unset($policy['InvalidLoginAttemptCount']);
+		unset($policy['DisablePremiumFeatures']);
+		unset($policy['DisablePremiumFeatures']);
+		return (json_encode($policy));
+	}
+	
 	public function checkHostPrefix($s)
 	{
 		if (empty($s)) {
@@ -197,7 +336,7 @@ trait OrganizrFunctions
 			$buttons .= '<button class="btn m-b-20 m-r-20 bg-primary text-muted waves-effect waves-light importUsersButton" onclick="importUsers(\'jellyfin\')" type="button"><span class="btn-label"><i class="mdi mdi-fish"></i></span><span lang="en">Import Jellyfin Users</span></button>';
 		}
 		if (!empty($this->config['embyURL']) && !empty($this->config['embyToken'])) {
-			$buttons .= '<button class="btn m-b-20 m-r-20 bg-emby text-muted waves-effect waves-light importUsersButton" onclick="importUsers(\'emby\')" type="button"><span class="btn-label"><i class="mdi mdi-emby"></i></span><span lang="en">Import Jellyfin Users</span></button>';
+			$buttons .= '<button class="btn m-b-20 m-r-20 bg-emby text-muted waves-effect waves-light importUsersButton" onclick="importUsers(\'emby\')" type="button"><span class="btn-label"><i class="mdi mdi-emby"></i></span><span lang="en">Import Emby Users</span></button>';
 		}
 		return ($buttons !== '') ? $buttons : $emptyButtons;
 	}
@@ -461,160 +600,23 @@ trait OrganizrFunctions
 		}
 		return $approved;
 	}
-}
-
-
-function embyJoinAPI($array)
-{
-	$username = ($array['username']) ?? null;
-	$email = ($array['email']) ?? null;
-	$password = ($array['password']) ?? null;
-	if (!$username) {
-		$GLOBALS['api']['response']['result'] = 'error';
-		$GLOBALS['api']['response']['message'] = 'Username not supplied';
-		return false;
-	}
-	if (!$email) {
-		$GLOBALS['api']['response']['result'] = 'error';
-		$GLOBALS['api']['response']['message'] = 'Email not supplied';
-		return false;
-	}
-	if (!$password) {
-		$GLOBALS['api']['response']['result'] = 'error';
-		$GLOBALS['api']['response']['message'] = 'Password not supplied';
-		return false;
-	}
-	return embyJoin($username, $email, $password);
-}
-
-function embyJoin($username, $email, $password)
-{
-	try {
-		#create user in emby.
-		$headers = array(
-			"Accept" => "application/json"
-		);
-		$data = array();
-		$url = $GLOBALS['embyURL'] . '/emby/Users/New?name=' . $username . '&api_key=' . $GLOBALS['embyToken'];
-		$response = Requests::Post($url, $headers, json_encode($data), array());
-		$response = $response->body;
-		//return($response);
-		$response = json_decode($response, true);
-		//return($response);
-		$userID = $response["Id"];
-		//return($userID);
-		#authenticate as user to update password.
-		//randomizer four digits of DeviceId
-		// I dont think ther would be security problems with hardcoding deviceID but randomizing it would mitigate any issue.
-		$deviceIdSeceret = rand(0, 9) . "" . rand(0, 9) . "" . rand(0, 9) . "" . rand(0, 9);
-		//hardcoded device id with the first three digits random 0-9,0-9,0-9,0-9
-		$embyAuthHeader = 'MediaBrowser Client="Emby Mobile", Device="Firefox", DeviceId="' . $deviceIdSeceret . 'aWxssS81LgAggFdpbmRvd3MgTlQgMTAuMDsgV2luNjxx7IHf2NCkgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzcyLjAuMzYyNi4xMTkgU2FmYXJpLzUzNy4zNnwxNTUxNTczMTAyNDI4", Version="4.0.2.0"';
-		$headers = array(
-			"Accept" => "application/json",
-			"Content-Type" => "application/json",
-			"X-Emby-Authorization" => $embyAuthHeader
-		);
-		$data = array(
-			"Pw" => "",
-			"Username" => $username
-		);
-		$url = $GLOBALS['embyURL'] . '/emby/Users/AuthenticateByName';
-		$response = Requests::Post($url, $headers, json_encode($data), array());
-		$response = $response->body;
-		$response = json_decode($response, true);
-		$userToken = $response["AccessToken"];
-		#update password
-		$embyAuthHeader = 'MediaBrowser Client="Emby Mobile", Device="Firefox", Token="' . $userToken . '", DeviceId="' . $deviceIdSeceret . 'aWxssS81LgAggFdpbmRvd3MgTlQgMTAuMDsgV2luNjxx7IHf2NCkgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzcyLjAuMzYyNi4xMTkgU2FmYXJpLzUzNy4zNnwxNTUxNTczMTAyNDI4", Version="4.0.2.0"';
-		$headers = array(
-			"Accept" => "application/json",
-			"Content-Type" => "application/json",
-			"X-Emby-Authorization" => $embyAuthHeader
-		);
-		$data = array(
-			"CurrentPw" => "",
-			"NewPw" => $password,
-			"Id" => $userID
-		);
-		$url = $GLOBALS['embyURL'] . '/emby/Users/' . $userID . '/Password';
-		Requests::Post($url, $headers, json_encode($data), array());
-		#update config
-		$headers = array(
-			"Accept" => "application/json",
-			"Content-Type" => "application/json"
-		);
-		$url = $GLOBALS['embyURL'] . '/emby/Users/' . $userID . '/Policy?api_key=' . $GLOBALS['embyToken'];
-		$response = Requests::Post($url, $headers, getEmbyTemplateUserJson(), array());
-		#add emby.media
-		try {
-			#seperate because this is not required
-			$headers = array(
-				"Accept" => "application/json",
-				"X-Emby-Authorization" => $embyAuthHeader
-			);
-			$data = array(
-				"ConnectUsername " => $email
-			);
-			$url = $GLOBALS['embyURL'] . '/emby/Users/' . $userID . '/Connect/Link';
-			Requests::Post($url, $headers, json_encode($data), array());
-		} catch (Requests_Exception $e) {
-			writeLog('error', 'Emby Connect Function - Error: ' . $e->getMessage(), 'SYSTEM');
-			$GLOBALS['api']['response']['message'] = $e->getMessage();
-			$GLOBALS['api']['response']['result'] = 'error';
-			return false;
+	
+	public function userDefinedIdReplacementLink($link, $variables)
+	{
+		return strtr($link, $variables);
+	}
+	
+	public function requestOptions($url, $override = false, $timeout = null)
+	{
+		$options = [];
+		if (is_numeric($timeout)) {
+			$timeout = $timeout / 1000;
+			array_push($options, array('timeout' => $timeout));
 		}
-		$GLOBALS['api']['response']['message'] = 'User has joined Emby';
-		return (true);
-		//return( "USERID:".$userID);
-	} catch (Requests_Exception $e) {
-		writeLog('error', 'Emby create Function - Error: ' . $e->getMessage(), 'SYSTEM');
-		$GLOBALS['api']['response']['message'] = $e->getMessage();
-		$GLOBALS['api']['response']['result'] = 'error';
-		return false;
-	};
-	return false;
-}
-
-/*loads users from emby and returns a correctly formated policy for a new user.
-*/
-function getEmbyTemplateUserJson()
-{
-	$headers = array(
-		"Accept" => "application/json"
-	);
-	$data = array();
-	$url = $GLOBALS['embyURL'] . '/emby/Users?api_key=' . $GLOBALS['embyToken'];
-	$response = Requests::Get($url, $headers, array());
-	$response = $response->body;
-	$response = json_decode($response, true);
-	//error_Log("response ".json_encode($response));
-	writeLog('error', 'userList:' . json_encode($response), 'SYSTEM');
-	//$correct stores the template users object
-	$correct = null;
-	foreach ($response as $element) {
-		if ($element['Name'] == $GLOBALS['INVITES-EmbyTemplate']) {
-			$correct = $element;
-		}
-	}
-	writeLog('error', 'Correct user:' . json_encode($correct), 'SYSTEM');
-	if ($correct == null) {
-		//return empty JSON if user incorectly configured template
-		return "{}";
-	}
-	//select policy section and remove possibly dangeours rows.
-	$policy = $correct['Policy'];
-	//writeLog('error', 'policy update'.$policy, 'SYSTEM');
-	unset($policy['AuthenticationProviderId']);
-	unset($policy['InvalidLoginAttemptCount']);
-	unset($policy['DisablePremiumFeatures']);
-	unset($policy['DisablePremiumFeatures']);
-	return (json_encode($policy));
-}
-
-function checkHostPrefix($s)
-{
-	if (empty($s)) {
-		return $s;
+		if ($this->localURL($url, $override)) {
+			array_push($options, array('verify' => false));
+			
+		}
+		return $options;
 	}
-	return (substr($s, -1, 1) == '\\') ? $s : $s . '\\';
-}
-
+}

+ 112 - 9
api/functions/sso-functions.php

@@ -2,30 +2,84 @@
 
 trait SSOFunctions
 {
+	public function getSSOUserFor($app, $userobj)
+	{
+		$map = array(
+			'jellyfin' => 'username',
+			'ombi' => 'username',
+			'overseerr' => 'username',
+			'tautulli' => 'username'
+		);
+		return (gettype($userobj) == 'string') ? $userobj : $userobj[$map[$app]];
+	}
 	
-	public function ssoCheck($username, $password, $token = null)
+	public function ssoCheck($userobj, $password, $token = null)
 	{
 		if ($this->config['ssoPlex'] && $token) {
 			$this->coookie('set', 'mpt', $token, $this->config['rememberMeDays'], false);
 		}
 		if ($this->config['ssoOmbi']) {
-			$ombiToken = $this->getOmbiToken($username, $password, $token);
+			$fallback = ($this->config['ombiFallbackUser'] !== '' && $this->config['ombiFallbackPassword'] !== '');
+			$ombiToken = $this->getOmbiToken($this->getSSOUserFor('ombi', $userobj), $password, $token, $fallback);
 			if ($ombiToken) {
 				$this->coookie('set', 'Auth', $ombiToken, $this->config['rememberMeDays'], false);
 			}
 		}
 		if ($this->config['ssoTautulli']) {
-			$tautulliToken = $this->getTautulliToken($username, $password, $token);
+			$tautulliToken = $this->getTautulliToken($this->getSSOUserFor('tautulli', $userobj), $password, $token);
 			if ($tautulliToken) {
 				foreach ($tautulliToken as $key => $value) {
 					$this->coookie('set', 'tautulli_token_' . $value['uuid'], $value['token'], $this->config['rememberMeDays'], true, $value['path']);
 				}
 			}
 		}
+		if ($this->config['ssoJellyfin']) {
+			$jellyfinToken = $this->getJellyfinToken($this->getSSOUserFor('jellyfin', $userobj), $password);
+			if ($jellyfinToken) {
+				$this->coookie('set', 'jellyfin_credentials', $jellyfinToken, $this->config['rememberMeDays'], false);
+			}
+		}
+		if ($this->config['ssoOverseerr']) {
+			$overseerrToken = $this->getOverseerrToken($this->getSSOUserFor('overseerr', $userobj), $password, $token);
+			if ($overseerrToken) {
+				$this->coookie('set', 'connect.sid', $overseerrToken, $this->config['rememberMeDays'], false);
+			}
+		}
 		return true;
 	}
 	
-	public function getOmbiToken($username, $password, $oAuthToken = null)
+	public function getJellyfinToken($username, $password)
+	{
+		$token = null;
+		try {
+			$url = $this->qualifyURL($this->config['jellyfinURL']);
+			$ssoUrl = $this->qualifyURL($this->config['jellyfinSSOURL']);
+			$headers = array(
+				'X-Emby-Authorization' => 'MediaBrowser Client="Organizr Jellyfin Tab", Device="Organizr_PHP", DeviceId="Organizr_SSO", Version="1.0"',
+				"Accept" => "application/json",
+				"Content-Type" => "application/json"
+			);
+			$data = array(
+				"Username" => $username,
+				"Pw" => $password
+			);
+			$endpoint = '/Users/authenticatebyname';
+			$options = $this->requestOptions($url, false, 60);
+			$response = Requests::post($url . $endpoint, $headers, json_encode($data), $options);
+			if ($response->success) {
+				$token = json_decode($response->body, true);
+				$this->writeLog('success', 'Jellyfin Token Function - Grabbed token.', $username);
+				return '{"Servers":[{"ManualAddress":"' . $ssoUrl . '","Id":"' . $token['ServerId'] . '","UserId":"' . $token['User']['Id'] . '","AccessToken":"' . $token['AccessToken'] . '"}]}';
+			} else {
+				$this->writeLog('error', 'Jellyfin Token Function - Jellyfin did not return Token', $username);
+			}
+		} catch (Requests_Exception $e) {
+			$this->writeLog('error', 'Jellyfin Token Function - Error: ' . $e->getMessage(), $username);
+		}
+		return false;
+	}
+	
+	public function getOmbiToken($username, $password, $oAuthToken = null, $fallback = false)
 	{
 		$token = null;
 		try {
@@ -41,18 +95,28 @@ trait SSOFunctions
 				"plexToken" => $oAuthToken
 			);
 			$endpoint = ($oAuthToken) ? '/api/v1/Token/plextoken' : '/api/v1/Token';
-			$options = ($this->localURL($url)) ? array('verify' => false) : array();
+			$options = $this->requestOptions($url, false, 60);
 			$response = Requests::post($url . $endpoint, $headers, json_encode($data), $options);
 			if ($response->success) {
 				$token = json_decode($response->body, true)['access_token'];
 				$this->writeLog('success', 'Ombi Token Function - Grabbed token.', $username);
 			} else {
-				$this->writeLog('error', 'Ombi Token Function - Ombi did not return Token', $username);
+				if ($fallback) {
+					$this->writeLog('error', 'Ombi Token Function - Ombi did not return Token - Will retry using fallback credentials', $username);
+				} else {
+					$this->writeLog('error', 'Ombi Token Function - Ombi did not return Token', $username);
+				}
 			}
 		} catch (Requests_Exception $e) {
 			$this->writeLog('error', 'Ombi Token Function - Error: ' . $e->getMessage(), $username);
 		}
-		return ($token) ? $token : false;
+		if ($token) {
+			return $token;
+		} elseif ($fallback) {
+			return $this->getOmbiToken($this->config['ombiFallbackUser'], $this->decrypt($this->config['ombiFallbackPassword']), null, false);
+		} else {
+			return false;
+		}
 	}
 	
 	public function getTautulliToken($username, $password, $plexToken = null)
@@ -74,7 +138,7 @@ trait SSOFunctions
 						"token" => $plexToken,
 						"remember_me" => 1,
 					);
-					$options = ($this->localURL($url)) ? array('verify' => false) : array();
+					$options = $this->requestOptions($url, false, 60);
 					$response = Requests::post($url . '/auth/signin', $headers, $data, $options);
 					if ($response->success) {
 						$qualifiedURL = $this->qualifyURL($url, true);
@@ -94,4 +158,43 @@ trait SSOFunctions
 		return ($token) ? $token : false;
 	}
 	
-}
+	public function getOverseerrToken($username, $password, $oAuthToken = null, $fallback = false)
+	{
+		$token = null;
+		try {
+			$url = $this->qualifyURL($this->config['overseerrURL']);
+			$headers = array(
+				"Content-Type" => "application/json"
+			);
+			$data = array(
+				//"username" => ($oAuthToken ? "" : $username), // not needed yet
+				//"password" => ($oAuthToken ? "" : $password), // not needed yet
+				"authToken" => $oAuthToken
+			);
+			$endpoint = '/api/v1/auth/plex';
+			$options = $this->requestOptions($url, false, 60);
+			$response = Requests::post($url . $endpoint, $headers, json_encode($data), $options);
+			if ($response->success) {
+				$user = json_decode($response->body, true); // not really needed yet
+				$token = $response->cookies['connect.sid']->value;
+				$this->writeLog('success', 'Overseerr Token Function - Grabbed token', $user['username']);
+			} else {
+				if ($fallback) {
+					$this->writeLog('error', 'Overseerr Token Function - Overseerr did not return Token - Will retry using fallback credentials', $username);
+				} else {
+					$this->writeLog('error', 'Overseerr Token Function - Overseerr did not return Token', $username);
+				}
+			}
+		} catch (Requests_Exception $e) {
+			$this->writeLog('error', 'Overseerr Token Function - Error: ' . $e->getMessage(), $username);
+		}
+		if ($token) {
+			return urldecode($token);
+		} elseif ($fallback) {
+			return $this->getOverseerrToken($this->config['overseerrFallbackUser'], $this->decrypt($this->config['overseerrFallbackPassword']), null, false);
+		} else {
+			return false;
+		}
+	}
+	
+}

+ 1 - 6
api/functions/static-globals.php

@@ -3,9 +3,4 @@
 trait StaticFunctions
 {
 
-}
-
-// ===================================
-// Organizr Version
-$GLOBALS['installedVersion'] = '2.0.740';
-// ===================================
+}

+ 6 - 6
api/functions/token-functions.php

@@ -25,15 +25,15 @@ trait TokenFunctions
 				$data->setAudience('Organizr');
 				if ($jwttoken->validate($data)) {
 					$result['valid'] = true;
-					$result['username'] = $jwttoken->getClaim('username');
-					$result['group'] = $jwttoken->getClaim('group');
-					$result['groupID'] = $jwttoken->getClaim('groupID');
+					//$result['username'] = $jwttoken->getClaim('username');
+					$result['group'] = ($jwttoken->hasClaim('group')) ? $jwttoken->getClaim('group') : 'N/A';
+					//$result['groupID'] = $jwttoken->getClaim('groupID');
 					$result['userID'] = $jwttoken->getClaim('userID');
-					$result['email'] = $jwttoken->getClaim('email');
-					$result['image'] = $jwttoken->getClaim('image');
+					//$result['email'] = $jwttoken->getClaim('email');
+					//$result['image'] = $jwttoken->getClaim('image');
 					$result['tokenExpire'] = $jwttoken->getClaim('exp');
 					$result['tokenDate'] = $jwttoken->getClaim('iat');
-					$result['token'] = $jwttoken->getClaim('exp');
+					//$result['token'] = $jwttoken->getClaim('exp');
 				}
 			}
 			if ($result['valid'] == true) {

+ 53 - 1
api/homepage/calendar.php

@@ -5,7 +5,7 @@ trait CalendarHomepageItem
 	public function calendarSettingsArray()
 	{
 		return array(
-			'name' => 'Calendar',
+			'name' => 'iCal',
 			'enabled' => strpos('personal', $this->config['license']) !== false,
 			'image' => 'plugins/images/tabs/calendar.png',
 			'category' => 'HOMEPAGE',
@@ -94,6 +94,54 @@ trait CalendarHomepageItem
 		);
 	}
 	
+	public function calendarHomepagePermissions($key = null)
+	{
+		$permissions = [
+			'main' => [
+				'enabled' => [
+					'homepageCalendarEnabled'
+				],
+				'auth' => [
+					'homepageCalendarAuth'
+				],
+				'not_empty' => [
+					'calendariCal'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
+		}
+	}
+	
+	public function homepageOrdercalendar()
+	{
+		if (
+			$this->homepageItemPermissions($this->sonarrHomepagePermissions('calendar')) ||
+			$this->homepageItemPermissions($this->radarrHomepagePermissions('calendar')) ||
+			$this->homepageItemPermissions($this->lidarrHomepagePermissions('calendar')) ||
+			$this->homepageItemPermissions($this->sickrageHomepagePermissions('calendar')) ||
+			$this->homepageItemPermissions($this->couchPotatoHomepagePermissions('calendar')) ||
+			$this->homepageItemPermissions($this->traktHomepagePermissions('calendar')) ||
+			$this->homepageItemPermissions($this->calendarHomepagePermissions('main'))
+		) {
+			return '
+				<div id="' . __FUNCTION__ . '">
+					<div id="calendar" class="fc fc-ltr m-b-30"></div>
+					<script>
+						// Calendar
+						homepageCalendar("' . $this->config['calendarRefresh'] . '");
+						// End Calendar
+					</script>
+					</div>
+				';
+		}
+	}
+	
 	public function loadCalendarJS()
 	{
 		$locale = ($this->config['calendarLocale'] !== 'en') ?? false;
@@ -126,6 +174,10 @@ trait CalendarHomepageItem
 		$items = $this->getCouchPotatoCalendar();
 		$calendarItems = is_array($items) ? array_merge($calendarItems, $items) : $calendarItems;
 		unset($items);
+		// TRAKT CONNECT
+		$items = $this->getTraktCalendar();
+		$calendarItems = is_array($items) ? array_merge($calendarItems, $items) : $calendarItems;
+		unset($items);
 		// iCal URL
 		$calendarSources['ical'] = $this->getICalendar();
 		unset($items);

+ 26 - 14
api/homepage/couchpotato.php

@@ -90,22 +90,34 @@ trait CouchPotatoHomepageItem
 		);
 	}
 	
-	public function getCouchPotatoCalendar()
+	public function couchPotatoHomepagePermissions($key = null)
 	{
-		if (!$this->config['homepageCouchpotatoEnabled']) {
-			$this->setAPIResponse('error', 'CouchPotato homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageCouchpotatoAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (empty($this->config['couchpotatoURL'])) {
-			$this->setAPIResponse('error', 'Radarr URL is not defined', 422);
-			return false;
+		$permissions = [
+			'calendar' => [
+				'enabled' => [
+					'homepageCouchpotatoEnabled'
+				],
+				'auth' => [
+					'homepageCouchpotatoAuth'
+				],
+				'not_empty' => [
+					'couchpotatoURL',
+					'couchpotatoToken'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
 		}
-		if (empty($this->config['couchpotatoToken'])) {
-			$this->setAPIResponse('error', 'Radarr Token is not defined', 422);
+	}
+	
+	public function getCouchPotatoCalendar()
+	{
+		if (!$this->homepageItemPermissions($this->couchPotatoHomepagePermissions('calendar'), true)) {
 			return false;
 		}
 		$calendarItems = array();

+ 52 - 21
api/homepage/deluge.php

@@ -128,34 +128,53 @@ trait DelugeHomepageItem
 		
 	}
 	
-	public function delugeStatus($queued, $status, $state)
+	public function delugeHomepagePermissions($key = null)
 	{
-		if ($queued == '-1' && $state == '100' && ($status == 'Seeding' || $status == 'Queued' || $status == 'Paused')) {
-			$state = 'Seeding';
-		} elseif ($state !== '100') {
-			$state = 'Downloading';
+		$permissions = [
+			'main' => [
+				'enabled' => [
+					'homepageDelugeEnabled'
+				],
+				'auth' => [
+					'homepageDelugeAuth'
+				],
+				'not_empty' => [
+					'delugeURL',
+					'delugePassword'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
 		} else {
-			$state = 'Finished';
+			return [];
 		}
-		return ($state) ? $state : $status;
 	}
 	
-	public function getDelugeHomepageQueue()
+	public function homepageOrderdeluge()
 	{
-		if (!$this->config['homepageDelugeEnabled']) {
-			$this->setAPIResponse('error', 'Deluge homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageDelugeAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (empty($this->config['delugeURL'])) {
-			$this->setAPIResponse('error', 'Deluge URL is not defined', 422);
-			return false;
+		if ($this->homepageItemPermissions($this->delugeHomepagePermissions('main'))) {
+			$loadingBox = ($this->config['delugeCombine']) ? '' : '<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Download Queue...</h2></div>';
+			$builder = ($this->config['delugeCombine']) ? 'buildDownloaderCombined(\'deluge\');' : '$("#' . __FUNCTION__ . '").html(buildDownloader("deluge"));';
+			return '
+				<div id="' . __FUNCTION__ . '">
+					' . $loadingBox . '
+					<script>
+		                // homepageOrderdeluge
+		                ' . $builder . '
+		                homepageDownloader("deluge", "' . $this->config['homepageDownloadRefresh'] . '");
+		                // End homepageOrderdeluge
+	                </script>
+				</div>
+				';
 		}
-		if (empty($this->config['delugePassword'])) {
-			$this->setAPIResponse('error', 'Deluge Password is not defined', 422);
+	}
+	
+	public function getDelugeHomepageQueue()
+	{
+		if (!$this->homepageItemPermissions($this->delugeHomepagePermissions('main'), true)) {
 			return false;
 		}
 		try {
@@ -182,4 +201,16 @@ trait DelugeHomepageItem
 		$this->setAPIResponse('success', null, 200, $api);
 		return $api;
 	}
+	
+	public function delugeStatus($queued, $status, $state)
+	{
+		if ($queued == '-1' && $state == '100' && ($status == 'Seeding' || $status == 'Queued' || $status == 'Paused')) {
+			$state = 'Seeding';
+		} elseif ($state !== '100') {
+			$state = 'Downloading';
+		} else {
+			$state = 'Finished';
+		}
+		return ($state) ? $state : $status;
+	}
 }

+ 250 - 210
api/homepage/emby.php

@@ -105,6 +105,13 @@ trait EmbyHomepageItem
 					),
 				),
 				'Misc Options' => array(
+					array(
+						'type' => 'input',
+						'name' => 'homepageEmbyLink',
+						'label' => 'Emby Homepage Link URL',
+						'value' => $this->config['homepageEmbyLink'],
+						'help' => 'Available variables: {id} {serverId}'
+					),
 					array(
 						'type' => 'input',
 						'name' => 'embyTabName',
@@ -190,6 +197,244 @@ trait EmbyHomepageItem
 		}
 	}
 	
+	public function embyHomepagePermissions($key = null)
+	{
+		$permissions = [
+			'streams' => [
+				'enabled' => [
+					'homepageEmbyEnabled',
+					'homepageEmbyStreams'
+				],
+				'auth' => [
+					'homepageEmbyAuth',
+					'homepageEmbyStreamsAuth'
+				],
+				'not_empty' => [
+					'embyURL',
+					'embyToken'
+				]
+			],
+			'recent' => [
+				'enabled' => [
+					'homepageEmbyEnabled',
+					'homepageEmbyRecent'
+				],
+				'auth' => [
+					'homepageEmbyAuth',
+					'homepageEmbyRecentAuth'
+				],
+				'not_empty' => [
+					'embyURL',
+					'embyToken'
+				]
+			],
+			'metadata' => [
+				'enabled' => [
+					'homepageEmbyEnabled'
+				],
+				'auth' => [
+					'homepageEmbyAuth'
+				],
+				'not_empty' => [
+					'embyURL',
+					'embyToken'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
+		}
+	}
+	
+	public function homepageOrderembynowplaying()
+	{
+		if ($this->homepageItemPermissions($this->embyHomepagePermissions('streams'))) {
+			return '
+				<div id="' . __FUNCTION__ . '">
+					<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Now Playing...</h2></div>
+					<script>
+						// Emby Stream
+						homepageStream("emby", "' . $this->config['homepageStreamRefresh'] . '");
+						// End Emby Stream
+					</script>
+				</div>
+				';
+		}
+	}
+	
+	public function homepageOrderembyrecent()
+	{
+		if ($this->homepageItemPermissions($this->embyHomepagePermissions('recent'))) {
+			return '
+				<div id="' . __FUNCTION__ . '">
+					<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Recent...</h2></div>
+					<script>
+						// Emby Recent
+						homepageRecent("emby", "' . $this->config['homepageRecentRefresh'] . '");
+						// End Emby Recent
+					</script>
+				</div>
+				';
+		}
+	}
+	
+	public function getEmbyHomepageStreams()
+	{
+		if (!$this->homepageItemPermissions($this->embyHomepagePermissions('streams'), true)) {
+			return false;
+		}
+		$url = $this->qualifyURL($this->config['embyURL']);
+		$url = $url . '/Sessions?api_key=' . $this->config['embyToken'] . '&Fields=Overview,People,Genres,CriticRating,Studios,Taglines';
+		$options = ($this->localURL($url)) ? array('verify' => false) : array();
+		try {
+			$response = Requests::get($url, array(), $options);
+			if ($response->success) {
+				$items = array();
+				$emby = json_decode($response->body, true);
+				foreach ($emby as $child) {
+					if (isset($child['NowPlayingItem']) || isset($child['Name'])) {
+						$items[] = $this->resolveEmbyItem($child);
+					}
+				}
+				$api['content'] = array_filter($items);
+				$this->setAPIResponse('success', null, 200, $api);
+				return $api;
+			} else {
+				$this->setAPIResponse('error', 'Emby Error Occurred', 500);
+				return false;
+			}
+		} catch (Requests_Exception $e) {
+			$this->writeLog('error', 'Emby Connect Function - Error: ' . $e->getMessage(), 'SYSTEM');
+			$this->setAPIResponse('error', $e->getMessage(), 500);
+			return false;
+		}
+	}
+	
+	public function getEmbyHomepageRecent()
+	{
+		if (!$this->homepageItemPermissions($this->embyHomepagePermissions('recent'), true)) {
+			return false;
+		}
+		$url = $this->qualifyURL($this->config['embyURL']);
+		$options = ($this->localURL($url)) ? array('verify' => false) : array();
+		$username = false;
+		$showPlayed = false;
+		$userId = 0;
+		try {
+			
+			
+			if (isset($this->user['username'])) {
+				$username = strtolower($this->user['username']);
+			}
+			// Get A User
+			$userIds = $url . "/Users?api_key=" . $this->config['embyToken'];
+			$response = Requests::get($userIds, array(), $options);
+			if ($response->success) {
+				$emby = json_decode($response->body, true);
+				foreach ($emby as $value) { // Scan for admin user
+					if (isset($value['Policy']) && isset($value['Policy']['IsAdministrator']) && $value['Policy']['IsAdministrator']) {
+						$userId = $value['Id'];
+					}
+					if ($username && strtolower($value['Name']) == $username) {
+						$userId = $value['Id'];
+						$showPlayed = false;
+						break;
+					}
+				}
+				$url = $url . '/Users/' . $userId . '/Items/Latest?EnableImages=true&Limit=' . $this->config['homepageRecentLimit'] . '&api_key=' . $this->config['embyToken'] . ($showPlayed ? '' : '&IsPlayed=false') . '&Fields=Overview,People,Genres,CriticRating,Studios,Taglines&IncludeItemTypes=Series,Episode,MusicAlbum,Audio,Movie,Video';
+			} else {
+				$this->setAPIResponse('error', 'Emby Error Occurred', 500);
+				return false;
+			}
+			$response = Requests::get($url, array(), $options);
+			if ($response->success) {
+				$items = array();
+				$emby = json_decode($response->body, true);
+				foreach ($emby as $child) {
+					if (isset($child['NowPlayingItem']) || isset($child['Name'])) {
+						$items[] = $this->resolveEmbyItem($child);
+					}
+				}
+				$api['content'] = array_filter($items);
+				$this->setAPIResponse('success', null, 200, $api);
+				return $api;
+			} else {
+				$this->setAPIResponse('error', 'Emby Error Occurred', 500);
+				return false;
+			}
+		} catch (Requests_Exception $e) {
+			$this->writeLog('error', 'Emby Connect Function - Error: ' . $e->getMessage(), 'SYSTEM');
+			$this->setAPIResponse('error', $e->getMessage(), 500);
+			return false;
+		}
+	}
+	
+	public function getEmbyHomepageMetadata($array)
+	{
+		if (!$this->homepageItemPermissions($this->embyHomepagePermissions('metadata'), true)) {
+			return false;
+		}
+		$key = $array['key'] ?? null;
+		if (!$key) {
+			$this->setAPIResponse('error', 'Emby Metadata key is not defined', 422);
+			return false;
+		}
+		$url = $this->qualifyURL($this->config['embyURL']);
+		$options = ($this->localURL($url)) ? array('verify' => false) : array();
+		$username = false;
+		$showPlayed = false;
+		$userId = 0;
+		try {
+			
+			
+			if (isset($this->user['username'])) {
+				$username = strtolower($this->user['username']);
+			}
+			// Get A User
+			$userIds = $url . "/Users?api_key=" . $this->config['embyToken'];
+			$response = Requests::get($userIds, array(), $options);
+			if ($response->success) {
+				$emby = json_decode($response->body, true);
+				foreach ($emby as $value) { // Scan for admin user
+					if (isset($value['Policy']) && isset($value['Policy']['IsAdministrator']) && $value['Policy']['IsAdministrator']) {
+						$userId = $value['Id'];
+					}
+					if ($username && strtolower($value['Name']) == $username) {
+						$userId = $value['Id'];
+						$showPlayed = false;
+						break;
+					}
+				}
+				$url = $url . '/Users/' . $userId . '/Items/' . $key . '?EnableImages=true&Limit=' . $this->config['homepageRecentLimit'] . '&api_key=' . $this->config['embyToken'] . ($showPlayed ? '' : '&IsPlayed=false') . '&Fields=Overview,People,Genres,CriticRating,Studios,Taglines';
+			} else {
+				$this->setAPIResponse('error', 'Emby Error Occurred', 500);
+				return false;
+			}
+			$response = Requests::get($url, array(), $options);
+			if ($response->success) {
+				$items = array();
+				$emby = json_decode($response->body, true);
+				if (isset($emby['NowPlayingItem']) || isset($emby['Name'])) {
+					$items[] = $this->resolveEmbyItem($emby);
+				}
+				$api['content'] = array_filter($items);
+				$this->setAPIResponse('success', null, 200, $api);
+				return $api;
+			} else {
+				$this->setAPIResponse('error', 'Emby Error Occurred', 500);
+				return false;
+			}
+		} catch (Requests_Exception $e) {
+			$this->writeLog('error', 'Emby Connect Function - Error: ' . $e->getMessage(), 'SYSTEM');
+			$this->setAPIResponse('error', $e->getMessage(), 500);
+			return false;
+		}
+	}
+	
 	public function resolveEmbyItem($itemDetails)
 	{
 		$item = isset($itemDetails['NowPlayingItem']['Id']) ? $itemDetails['NowPlayingItem'] : $itemDetails;
@@ -300,8 +545,11 @@ trait EmbyHomepageItem
 		$embyItem['user'] = ($this->config['homepageShowStreamNames'] && $this->qualifyRequest($this->config['homepageShowStreamNamesAuth'])) ? @(string)$itemDetails['UserName'] : "";
 		$embyItem['userThumb'] = '';
 		$embyItem['userAddress'] = (isset($itemDetails['RemoteEndPoint']) ? $itemDetails['RemoteEndPoint'] : "x.x.x.x");
-		$embyURL = 'https://app.emby.media/#!/item/item.html?id=';
-		$embyItem['address'] = $this->config['embyTabURL'] ? rtrim($this->config['embyTabURL'], '/') . "/web/#!/item/item.html?id=" . $embyItem['uid'] : $embyURL . $embyItem['uid'] . "&serverId=" . $embyItem['id'];
+		$embyVariablesForLink = [
+			'{id}' => $embyItem['uid'],
+			'{serverId}' => $embyItem['id']
+		];
+		$embyItem['address'] = $this->userDefinedIdReplacementLink($this->config['homepageEmbyLink'], $embyVariablesForLink);
 		$embyItem['nowPlayingOriginalImage'] = 'api/v2/homepage/image?source=emby&type=' . $embyItem['nowPlayingImageType'] . '&img=' . $embyItem['nowPlayingThumb'] . '&height=' . $nowPlayingHeight . '&width=' . $nowPlayingWidth . '&key=' . $embyItem['nowPlayingKey'] . '$' . $this->randString();
 		$embyItem['originalImage'] = 'api/v2/homepage/image?source=emby&type=' . $embyItem['imageType'] . '&img=' . $embyItem['thumb'] . '&height=' . $height . '&width=' . $width . '&key=' . $embyItem['key'] . '$' . $this->randString();
 		$embyItem['openTab'] = $this->config['embyTabURL'] && $this->config['embyTabName'] ? true : false;
@@ -388,212 +636,4 @@ trait EmbyHomepageItem
 		return $embyItem;
 	}
 	
-	public function getEmbyHomepageStreams()
-	{
-		if (!$this->config['homepageEmbyEnabled']) {
-			$this->setAPIResponse('error', 'Emby homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->config['homepageEmbyStreams']) {
-			$this->setAPIResponse('error', 'Emby homepage module is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageEmbyAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageEmbyStreamsAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage module', 401);
-			return false;
-		}
-		if (empty($this->config['embyURL'])) {
-			$this->setAPIResponse('error', 'Emby URL is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['embyToken'])) {
-			$this->setAPIResponse('error', 'Emby Token is not defined', 422);
-			return false;
-		}
-		$url = $this->qualifyURL($this->config['embyURL']);
-		$url = $url . '/Sessions?api_key=' . $this->config['embyToken'] . '&Fields=Overview,People,Genres,CriticRating,Studios,Taglines';
-		$options = ($this->localURL($url)) ? array('verify' => false) : array();
-		try {
-			$response = Requests::get($url, array(), $options);
-			if ($response->success) {
-				$items = array();
-				$emby = json_decode($response->body, true);
-				foreach ($emby as $child) {
-					if (isset($child['NowPlayingItem']) || isset($child['Name'])) {
-						$items[] = $this->resolveEmbyItem($child);
-					}
-				}
-				$api['content'] = array_filter($items);
-				$this->setAPIResponse('success', null, 200, $api);
-				return $api;
-			} else {
-				$this->setAPIResponse('error', 'Emby Error Occurred', 500);
-				return false;
-			}
-		} catch (Requests_Exception $e) {
-			$this->writeLog('error', 'Emby Connect Function - Error: ' . $e->getMessage(), 'SYSTEM');
-			$this->setAPIResponse('error', $e->getMessage(), 500);
-			return false;
-		}
-	}
-	
-	public function getEmbyHomepageRecent()
-	{
-		if (!$this->config['homepageEmbyEnabled']) {
-			$this->setAPIResponse('error', 'Emby homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->config['homepageEmbyRecent']) {
-			$this->setAPIResponse('error', 'Emby homepage module is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageEmbyAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageEmbyRecentAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage module', 401);
-			return false;
-		}
-		if (empty($this->config['embyURL'])) {
-			$this->setAPIResponse('error', 'Emby URL is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['embyToken'])) {
-			$this->setAPIResponse('error', 'Emby Token is not defined', 422);
-			return false;
-		}
-		$url = $this->qualifyURL($this->config['embyURL']);
-		$options = ($this->localURL($url)) ? array('verify' => false) : array();
-		$username = false;
-		$showPlayed = false;
-		$userId = 0;
-		try {
-			
-			
-			if (isset($this->user['username'])) {
-				$username = strtolower($this->user['username']);
-			}
-			// Get A User
-			$userIds = $url . "/Users?api_key=" . $this->config['embyToken'];
-			$response = Requests::get($userIds, array(), $options);
-			if ($response->success) {
-				$emby = json_decode($response->body, true);
-				foreach ($emby as $value) { // Scan for admin user
-					if (isset($value['Policy']) && isset($value['Policy']['IsAdministrator']) && $value['Policy']['IsAdministrator']) {
-						$userId = $value['Id'];
-					}
-					if ($username && strtolower($value['Name']) == $username) {
-						$userId = $value['Id'];
-						$showPlayed = false;
-						break;
-					}
-				}
-				$url = $url . '/Users/' . $userId . '/Items/Latest?EnableImages=true&Limit=' . $this->config['homepageRecentLimit'] . '&api_key=' . $this->config['embyToken'] . ($showPlayed ? '' : '&IsPlayed=false') . '&Fields=Overview,People,Genres,CriticRating,Studios,Taglines';
-			} else {
-				$this->setAPIResponse('error', 'Emby Error Occurred', 500);
-				return false;
-			}
-			$response = Requests::get($url, array(), $options);
-			if ($response->success) {
-				$items = array();
-				$emby = json_decode($response->body, true);
-				foreach ($emby as $child) {
-					if (isset($child['NowPlayingItem']) || isset($child['Name'])) {
-						$items[] = $this->resolveEmbyItem($child);
-					}
-				}
-				$api['content'] = array_filter($items);
-				$this->setAPIResponse('success', null, 200, $api);
-				return $api;
-			} else {
-				$this->setAPIResponse('error', 'Emby Error Occurred', 500);
-				return false;
-			}
-		} catch (Requests_Exception $e) {
-			$this->writeLog('error', 'Emby Connect Function - Error: ' . $e->getMessage(), 'SYSTEM');
-			$this->setAPIResponse('error', $e->getMessage(), 500);
-			return false;
-		}
-	}
-	
-	public function getEmbyHomepageMetadata($array)
-	{
-		if (!$this->config['homepageEmbyEnabled']) {
-			$this->setAPIResponse('error', 'Emby homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageEmbyAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (empty($this->config['embyURL'])) {
-			$this->setAPIResponse('error', 'Emby URL is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['embyToken'])) {
-			$this->setAPIResponse('error', 'Emby Token is not defined', 422);
-			return false;
-		}
-		$key = $array['key'] ?? null;
-		if (!$key) {
-			$this->setAPIResponse('error', 'Emby Metadata key is not defined', 422);
-			return false;
-		}
-		$url = $this->qualifyURL($this->config['embyURL']);
-		$options = ($this->localURL($url)) ? array('verify' => false) : array();
-		$username = false;
-		$showPlayed = false;
-		$userId = 0;
-		try {
-			
-			
-			if (isset($this->user['username'])) {
-				$username = strtolower($this->user['username']);
-			}
-			// Get A User
-			$userIds = $url . "/Users?api_key=" . $this->config['embyToken'];
-			$response = Requests::get($userIds, array(), $options);
-			if ($response->success) {
-				$emby = json_decode($response->body, true);
-				foreach ($emby as $value) { // Scan for admin user
-					if (isset($value['Policy']) && isset($value['Policy']['IsAdministrator']) && $value['Policy']['IsAdministrator']) {
-						$userId = $value['Id'];
-					}
-					if ($username && strtolower($value['Name']) == $username) {
-						$userId = $value['Id'];
-						$showPlayed = false;
-						break;
-					}
-				}
-				$url = $url . '/Users/' . $userId . '/Items/' . $key . '?EnableImages=true&Limit=' . $this->config['homepageRecentLimit'] . '&api_key=' . $this->config['embyToken'] . ($showPlayed ? '' : '&IsPlayed=false') . '&Fields=Overview,People,Genres,CriticRating,Studios,Taglines';
-			} else {
-				$this->setAPIResponse('error', 'Emby Error Occurred', 500);
-				return false;
-			}
-			$response = Requests::get($url, array(), $options);
-			if ($response->success) {
-				$items = array();
-				$emby = json_decode($response->body, true);
-				if (isset($emby['NowPlayingItem']) || isset($emby['Name'])) {
-					$items[] = $this->resolveEmbyItem($emby);
-				}
-				$api['content'] = array_filter($items);
-				$this->setAPIResponse('success', null, 200, $api);
-				return $api;
-			} else {
-				$this->setAPIResponse('error', 'Emby Error Occurred', 500);
-				return false;
-			}
-		} catch (Requests_Exception $e) {
-			$this->writeLog('error', 'Emby Connect Function - Error: ' . $e->getMessage(), 'SYSTEM');
-			$this->setAPIResponse('error', $e->getMessage(), 500);
-			return false;
-		}
-	}
-	
 }

+ 55 - 27
api/homepage/healthchecks.php

@@ -62,39 +62,50 @@ trait HealthChecksHomepageItem
 		);
 	}
 	
-	public function healthChecksTags($tags)
+	public function healthChecksHomepagePermissions($key = null)
 	{
-		$return = '?tag=';
-		if (!$tags) {
-			return '';
-		} elseif ($tags == '*') {
-			return '';
+		$permissions = [
+			'main' => [
+				'enabled' => [
+					'homepageHealthChecksEnabled'
+				],
+				'auth' => [
+					'homepageHealthChecksAuth'
+				],
+				'not_empty' => [
+					'healthChecksURL',
+					'healthChecksToken'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
 		} else {
-			if (strpos($tags, ',') !== false) {
-				$list = explode(',', $tags);
-				return $return . implode("&tag=", $list);
-			} else {
-				return $return . $tags;
-			}
+			return [];
 		}
 	}
 	
-	public function getHealthChecks($tags = null)
+	public function homepageOrderhealthchecks()
 	{
-		if (!$this->config['homepageHealthChecksEnabled']) {
-			$this->setAPIResponse('error', 'HealthChecks homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageHealthChecksAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (empty($this->config['healthChecksURL'])) {
-			$this->setAPIResponse('error', 'HealthChecks URL is not defined', 422);
-			return false;
+		if ($this->homepageItemPermissions($this->healthChecksHomepagePermissions('main'))) {
+			return '
+				<div id="' . __FUNCTION__ . '">
+					<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Health Checks...</h2></div>
+					<script>
+						// Health Checks
+						homepageHealthChecks("' . $this->config['healthChecksTags'] . '","' . $this->config['homepageHealthChecksRefresh'] . '");
+						// End Health Checks
+					</script>
+				</div>
+				';
 		}
-		if (empty($this->config['healthChecksToken'])) {
-			$this->setAPIResponse('error', 'HealthChecks Token is not defined', 422);
+	}
+	
+	public function getHealthChecks($tags = null)
+	{
+		if (!$this->homepageItemPermissions($this->healthChecksHomepagePermissions('main'), true)) {
 			return false;
 		}
 		$api['content']['checks'] = array();
@@ -104,7 +115,7 @@ trait HealthChecksHomepageItem
 			$url = $this->qualifyURL($this->config['healthChecksURL']) . '/' . $tags;
 			try {
 				$headers = array('X-Api-Key' => $token);
-				$options = ($this->localURL($url)) ? array('verify' => false) : array();
+				$options = ($this->localURL($url)) ? array('verify' => false) : array('verify' => $this->getCert());
 				$response = Requests::get($url, $headers, $options);
 				if ($response->success) {
 					$healthResults = json_decode($response->body, true);
@@ -121,4 +132,21 @@ trait HealthChecksHomepageItem
 		$this->setAPIResponse('success', null, 200, $api);
 		return $api;
 	}
+	
+	public function healthChecksTags($tags)
+	{
+		$return = '?tag=';
+		if (!$tags) {
+			return '';
+		} elseif ($tags == '*') {
+			return '';
+		} else {
+			if (strpos($tags, ',') !== false) {
+				$list = explode(',', $tags);
+				return $return . implode("&tag=", $list);
+			} else {
+				return $return . $tags;
+			}
+		}
+	}
 }

+ 145 - 0
api/homepage/html.php

@@ -0,0 +1,145 @@
+<?php
+
+trait HTMLHomepageItem
+{
+	public function htmlOneSettingsArray()
+	{
+		return array(
+			'name' => 'CustomHTML-1',
+			'enabled' => strpos('personal,business', $this->config['license']) !== false,
+			'image' => 'plugins/images/tabs/custom1.png',
+			'category' => 'Custom',
+			'settings' => array(
+				'Enable' => array(
+					array(
+						'type' => 'switch',
+						'name' => 'homepageCustomHTMLoneEnabled',
+						'label' => 'Enable',
+						'value' => $this->config['homepageCustomHTMLoneEnabled']
+					),
+					array(
+						'type' => 'select',
+						'name' => 'homepageCustomHTMLoneAuth',
+						'label' => 'Minimum Authentication',
+						'value' => $this->config['homepageCustomHTMLoneAuth'],
+						'options' => $this->groupOptions
+					)
+				),
+				'Code' => array(
+					array(
+						'type' => 'textbox',
+						'name' => 'customHTMLone',
+						'class' => 'hidden customHTMLoneTextarea',
+						'label' => '',
+						'value' => $this->config['customHTMLone'],
+					),
+					array(
+						'type' => 'html',
+						'override' => 12,
+						'label' => 'Custom HTML/JavaScript',
+						'html' => '<button type="button" class="hidden savecustomHTMLoneTextarea btn btn-info btn-circle pull-right m-r-5 m-l-10"><i class="fa fa-save"></i> </button><div id="customHTMLoneEditor" style="height:300px">' . htmlentities($this->config['customHTMLone']) . '</div>'
+					),
+				)
+			)
+		);
+	}
+	
+	public function htmlTwoSettingsArray()
+	{
+		return array(
+			'name' => 'CustomHTML-2',
+			'enabled' => strpos('personal,business', $this->config['license']) !== false,
+			'image' => 'plugins/images/tabs/custom2.png',
+			'category' => 'Custom',
+			'settings' => array(
+				'Enable' => array(
+					array(
+						'type' => 'switch',
+						'name' => 'homepageCustomHTMLtwoEnabled',
+						'label' => 'Enable',
+						'value' => $this->config['homepageCustomHTMLtwoEnabled']
+					),
+					array(
+						'type' => 'select',
+						'name' => 'homepageCustomHTMLtwoAuth',
+						'label' => 'Minimum Authentication',
+						'value' => $this->config['homepageCustomHTMLtwoAuth'],
+						'options' => $this->groupOptions
+					)
+				),
+				'Code' => array(
+					array(
+						'type' => 'textbox',
+						'name' => 'customHTMLtwo',
+						'class' => 'hidden customHTMLtwoTextarea',
+						'label' => '',
+						'value' => $this->config['customHTMLtwo'],
+					),
+					array(
+						'type' => 'html',
+						'override' => 12,
+						'label' => 'Custom HTML/JavaScript',
+						'html' => '<button type="button" class="hidden savecustomHTMLtwoTextarea btn btn-info btn-circle pull-right m-r-5 m-l-10"><i class="fa fa-save"></i> </button><div id="customHTMLtwoEditor" style="height:300px">' . htmlentities($this->config['customHTMLtwo']) . '</div>'
+					),
+				)
+			)
+		);
+	}
+	
+	public function htmlHomepagePermissions($key = null)
+	{
+		$permissions = [
+			'one' => [
+				'enabled' => [
+					'homepageCustomHTMLoneEnabled'
+				],
+				'auth' => [
+					'homepageCustomHTMLoneAuth'
+				],
+				'not_empty' => [
+					'customHTMLone'
+				]
+			],
+			'two' => [
+				'enabled' => [
+					'homepageCustomHTMLtwoEnabled'
+				],
+				'auth' => [
+					'homepageCustomHTMLtwoAuth'
+				],
+				'not_empty' => [
+					'customHTMLtwo'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
+		}
+	}
+	
+	public function homepageOrdercustomhtml()
+	{
+		if ($this->homepageItemPermissions($this->htmlHomepagePermissions('one'))) {
+			return '
+				<div id="' . __FUNCTION__ . '">
+					' . $this->config['customHTMLone'] . '
+				</div>
+				';
+		}
+	}
+	
+	public function homepageOrdercustomhtmlTwo()
+	{
+		if ($this->homepageItemPermissions($this->htmlHomepagePermissions('two'))) {
+			return '
+				<div id="' . __FUNCTION__ . '">
+					' . $this->config['customHTMLtwo'] . '
+				</div>
+				';
+		}
+	}
+}

+ 119 - 0
api/homepage/jackett.php

@@ -0,0 +1,119 @@
+<?php
+
+trait JackettHomepageItem
+{
+	public function jackettSettingsArray()
+	{
+		return array(
+			'name' => 'Jackett',
+			'enabled' => true,
+			'image' => 'plugins/images/tabs/jackett.png',
+			'category' => 'Utility',
+			'settings' => array(
+				'Enable' => array(
+					array(
+						'type' => 'switch',
+						'name' => 'homepageJackettEnabled',
+						'label' => 'Enable',
+						'value' => $this->config['homepageJackettEnabled']
+					),
+					array(
+						'type' => 'select',
+						'name' => 'homepageJackettAuth',
+						'label' => 'Minimum Authentication',
+						'value' => $this->config['homepageJackettAuth'],
+						'options' => $this->groupOptions
+					)
+				),
+				'Connection' => array(
+					array(
+						'type' => 'input',
+						'name' => 'jackettURL',
+						'label' => 'URL',
+						'value' => $this->config['jackettURL'],
+						'help' => 'Please make sure to use local IP address and port - You also may use local dns name too.',
+						'placeholder' => 'http(s)://hostname:port'
+					),
+					array(
+						'type' => 'password-alt',
+						'name' => 'jackettToken',
+						'label' => 'Token',
+						'value' => $this->config['jackettToken']
+					)
+				),
+				'Options' => array(),
+			)
+		);
+	}
+	
+	public function jackettHomepagePermissions($key = null)
+	{
+		$permissions = [
+			'main' => [
+				'enabled' => [
+					'homepageJackettEnabled'
+				],
+				'auth' => [
+					'homepageJackettAuth'
+				],
+				'not_empty' => [
+					'jackettURL',
+					'jackettToken'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
+		}
+	}
+	
+	public function homepageOrderJackett()
+	{
+		if ($this->homepageItemPermissions($this->jackettHomepagePermissions('main'))) {
+			return '
+				<div id="' . __FUNCTION__ . '">
+					<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Jackett...</h2></div>
+					<script>
+						// Jackett
+						homepageJackett();
+						// End Jackett
+					</script>
+				</div>
+				';
+		}
+	}
+	
+	public function searchJackettIndexers($query = null)
+	{
+		if (!$this->homepageItemPermissions($this->jackettHomepagePermissions('main'), true)) {
+			return false;
+		}
+		if (!$query) {
+			$this->setAPIResponse('error', 'Query was not supplied', 422);
+			return false;
+		}
+		$apiURL = $this->qualifyURL($this->config['jackettURL']);
+		$endpoint = $apiURL . '/api/v2.0/indexers/all/results?apikey=' . $this->config['jackettToken'] . '&Query=' . urlencode($query);
+		try {
+			$headers = array();
+			$options = array('timeout' => 120);
+			$response = Requests::get($endpoint, $headers, $options);
+			if ($response->success) {
+				$apiData = json_decode($response->body, true);
+				$api['content'] = $apiData;
+				unset($apiData);
+			}
+		} catch (Requests_Exception $e) {
+			$this->writeLog('error', 'Weather And Air Connect Function - Error: ' . $e->getMessage(), 'SYSTEM');
+			$this->setAPIResponse('error', $e->getMessage(), 500);
+			return false;
+		};
+		$api['content'] = isset($api['content']) ? $api['content'] : false;
+		$this->setAPIResponse('success', null, 200, $api);
+		return $api;
+	}
+}

+ 45 - 11
api/homepage/jdownloader.php

@@ -95,7 +95,7 @@ trait JDownloaderHomepageItem
 		}
 		$url = $this->qualifyURL($this->config['jdownloaderURL']);
 		try {
-			$options = ($this->localURL($url)) ? array('verify' => false, 'timeout' => 30) : array('timeout' => 30);
+			$options = $this->requestOptions($this->config['jdownloaderURL'], false, $this->config['homepageDownloadRefresh']);
 			$response = Requests::get($url, array(), $options);
 			if ($response->success) {
 				$this->setAPIResponse('success', 'API Connection succeeded', 200);
@@ -111,23 +111,57 @@ trait JDownloaderHomepageItem
 		};
 	}
 	
-	public function getJdownloaderHomepageQueue()
+	public function jDownloaderHomepagePermissions($key = null)
 	{
-		if (!$this->config['homepageJdownloaderEnabled']) {
-			$this->setAPIResponse('error', 'JDownloader homepage item is not enabled', 409);
-			return false;
+		$permissions = [
+			'main' => [
+				'enabled' => [
+					'homepageJdownloaderEnabled'
+				],
+				'auth' => [
+					'homepageJdownloaderAuth'
+				],
+				'not_empty' => [
+					'jdownloaderURL'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
 		}
-		if (!$this->qualifyRequest($this->config['homepageJdownloaderAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
+	}
+	
+	public function homepageOrderjdownloader()
+	{
+		if ($this->homepageItemPermissions($this->jDownloaderHomepagePermissions('main'))) {
+			$loadingBox = ($this->config['jdownloaderCombine']) ? '' : '<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Download Queue...</h2></div>';
+			$builder = ($this->config['jdownloaderCombine']) ? 'buildDownloaderCombined(\'jdownloader\');' : '$("#' . __FUNCTION__ . '").html(buildDownloader("jdownloader"));';
+			return '
+				<div id="' . __FUNCTION__ . '">
+					' . $loadingBox . '
+					<script>
+		                // homepageOrderjdownloader
+		                ' . $builder . '
+		                homepageDownloader("jdownloader", "' . $this->config['homepageDownloadRefresh'] . '");
+		                // End homepageOrderjdownloader
+	                </script>
+				</div>
+				';
 		}
-		if (empty($this->config['jdownloaderURL'])) {
-			$this->setAPIResponse('error', 'JDownloader URL is not defined', 422);
+	}
+	
+	public function getJdownloaderHomepageQueue()
+	{
+		if (!$this->homepageItemPermissions($this->jDownloaderHomepagePermissions('main'), true)) {
 			return false;
 		}
 		$url = $this->qualifyURL($this->config['jdownloaderURL']);
 		try {
-			$options = ($this->localURL($url)) ? array('verify' => false, 'timeout' => 30) : array('timeout' => 30);
+			$options = $this->requestOptions($this->config['jdownloaderURL'], false, $this->config['homepageDownloadRefresh']);
 			$response = Requests::get($url, array(), $options);
 			if ($response->success) {
 				$temp = json_decode($response->body, true);

+ 246 - 206
api/homepage/jellyfin.php

@@ -106,6 +106,13 @@ trait JellyfinHomepageItem
 					),
 				),
 				'Misc Options' => array(
+					array(
+						'type' => 'input',
+						'name' => 'homepageJellyfinLink',
+						'label' => 'Jellyfin Homepage Link URL',
+						'value' => $this->config['homepageJellyfinLink'],
+						'help' => 'Available variables: {id} {serverId}'
+					),
 					array(
 						'type' => 'input',
 						'name' => 'jellyfinTabName',
@@ -197,6 +204,240 @@ trait JellyfinHomepageItem
 		}
 	}
 	
+	public function jellyfinHomepagePermissions($key = null)
+	{
+		$permissions = [
+			'streams' => [
+				'enabled' => [
+					'homepageJellyfinEnabled',
+					'homepageJellyfinStreams'
+				],
+				'auth' => [
+					'homepageJellyfinAuth',
+					'homepageJellyStreamsAuth'
+				],
+				'not_empty' => [
+					'jellyfinURL',
+					'jellyfinToken'
+				]
+			],
+			'recent' => [
+				'enabled' => [
+					'homepageJellyfinEnabled',
+					'homepageJellyfinRecent'
+				],
+				'auth' => [
+					'homepageJellyfinAuth',
+					'homepageJellyfinRecentAuth'
+				],
+				'not_empty' => [
+					'jellyfinURL',
+					'jellyfinToken'
+				]
+			],
+			'metadata' => [
+				'enabled' => [
+					'homepageJellyfinEnabled'
+				],
+				'auth' => [
+					'homepageJellyfinAuth'
+				],
+				'not_empty' => [
+					'jellyfinURL',
+					'jellyfinToken'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
+		}
+	}
+	
+	public function homepageOrderjellyfinnowplaying()
+	{
+		if ($this->homepageItemPermissions($this->jellyfinHomepagePermissions('streams'))) {
+			return '
+				<div id="' . __FUNCTION__ . '">
+					<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Now Playing...</h2></div>
+					<script>
+						// Jellyfin Stream
+						homepageStream("jellyfin", "' . $this->config['homepageStreamRefresh'] . '");
+						// End Jellyfin Stream
+					</script>
+				</div>
+				';
+		}
+	}
+	
+	public function homepageOrderjellyfinrecent()
+	{
+		if ($this->homepageItemPermissions($this->jellyfinHomepagePermissions('recent'))) {
+			return '
+				<div id="' . __FUNCTION__ . '">
+					<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Recent...</h2></div>
+					<script>
+						// Jellyfin Recent
+						homepageRecent("jellyfin", "' . $this->config['homepageRecentRefresh'] . '");
+						// End Jellyfin Recent
+					</script>
+				</div>
+				';
+		}
+	}
+	
+	public function getJellyfinHomepageStreams()
+	{
+		if (!$this->homepageItemPermissions($this->jellyfinHomepagePermissions('streams'), true)) {
+			return false;
+		}
+		$url = $this->qualifyURL($this->config['jellyfinURL']);
+		$url = $url . '/Sessions?api_key=' . $this->config['jellyfinToken'] . '&Fields=Overview,People,Genres,CriticRating,Studios,Taglines';
+		$options = ($this->localURL($url)) ? array('verify' => false) : array();
+		try {
+			$response = Requests::get($url, array(), $options);
+			if ($response->success) {
+				$items = array();
+				$jellyfin = json_decode($response->body, true);
+				foreach ($jellyfin as $child) {
+					if (isset($child['NowPlayingItem']) || isset($child['Name'])) {
+						$items[] = $this->resolveJellyfinItem($child);
+					}
+				}
+				$api['content'] = array_filter($items);
+				$this->setAPIResponse('success', null, 200, $api);
+				return $api;
+			} else {
+				$this->setAPIResponse('error', 'Jellyfin Error Occurred', 500);
+				return false;
+			}
+		} catch (Requests_Exception $e) {
+			$this->writeLog('error', 'Jellyfin Connect Function - Error: ' . $e->getMessage(), 'SYSTEM');
+			$this->setAPIResponse('error', $e->getMessage(), 500);
+			return false;
+		}
+	}
+	
+	public function getJellyfinHomepageRecent()
+	{
+		if (!$this->homepageItemPermissions($this->jellyfinHomepagePermissions('recent'), true)) {
+			return false;
+		}
+		$url = $this->qualifyURL($this->config['jellyfinURL']);
+		$options = ($this->localURL($url)) ? array('verify' => false) : array();
+		$username = false;
+		$showPlayed = false;
+		$userId = 0;
+		try {
+			if (isset($this->user['username'])) {
+				$username = strtolower($this->user['username']);
+			}
+			// Get A User
+			$userIds = $url . "/Users?api_key=" . $this->config['jellyfinToken'];
+			$response = Requests::get($userIds, array(), $options);
+			if ($response->success) {
+				$jellyfin = json_decode($response->body, true);
+				foreach ($jellyfin as $value) { // Scan for admin user
+					if (isset($value['Policy']) && isset($value['Policy']['IsAdministrator']) && $value['Policy']['IsAdministrator']) {
+						$userId = $value['Id'];
+					}
+					if ($username && strtolower($value['Name']) == $username) {
+						$userId = $value['Id'];
+						$showPlayed = false;
+						break;
+					}
+				}
+				$url = $url . '/Users/' . $userId . '/Items/Latest?EnableImages=true&Limit=' . $this->config['homepageRecentLimit'] . '&api_key=' . $this->config['jellyfinToken'] . ($showPlayed ? '' : '&IsPlayed=false') . '&Fields=Overview,People,Genres,CriticRating,Studios,Taglines';
+			} else {
+				$this->setAPIResponse('error', 'Jellyfin Error Occurred', 500);
+				return false;
+			}
+			$response = Requests::get($url, array(), $options);
+			if ($response->success) {
+				$items = array();
+				$jellyfin = json_decode($response->body, true);
+				foreach ($jellyfin as $child) {
+					if (isset($child['NowPlayingItem']) || isset($child['Name'])) {
+						$items[] = $this->resolveJellyfinItem($child);
+					}
+				}
+				$api['content'] = array_filter($items);
+				$this->setAPIResponse('success', null, 200, $api);
+				return $api;
+			} else {
+				$this->setAPIResponse('error', 'Jellyfin Error Occurred', 500);
+				return false;
+			}
+		} catch (Requests_Exception $e) {
+			$this->writeLog('error', 'Jellyfin Connect Function - Error: ' . $e->getMessage(), 'SYSTEM');
+			$this->setAPIResponse('error', $e->getMessage(), 500);
+			return false;
+		}
+	}
+	
+	public function getJellyfinHomepageMetadata($array)
+	{
+		if (!$this->homepageItemPermissions($this->jellyfinHomepagePermissions('metadata'), true)) {
+			return false;
+		}
+		$key = $array['key'] ?? null;
+		if (!$key) {
+			$this->setAPIResponse('error', 'Jellyfin Metadata key is not defined', 422);
+			return false;
+		}
+		$url = $this->qualifyURL($this->config['jellyfinURL']);
+		$options = ($this->localURL($url)) ? array('verify' => false) : array();
+		$username = false;
+		$showPlayed = false;
+		$userId = 0;
+		try {
+			if (isset($this->user['username'])) {
+				$username = strtolower($this->user['username']);
+			}
+			// Get A User
+			$userIds = $url . "/Users?api_key=" . $this->config['jellyfinToken'];
+			$response = Requests::get($userIds, array(), $options);
+			if ($response->success) {
+				$jellyfin = json_decode($response->body, true);
+				foreach ($jellyfin as $value) { // Scan for admin user
+					if (isset($value['Policy']) && isset($value['Policy']['IsAdministrator']) && $value['Policy']['IsAdministrator']) {
+						$userId = $value['Id'];
+					}
+					if ($username && strtolower($value['Name']) == $username) {
+						$userId = $value['Id'];
+						$showPlayed = false;
+						break;
+					}
+				}
+				$url = $url . '/Users/' . $userId . '/Items/' . $key . '?EnableImages=true&Limit=' . $this->config['homepageRecentLimit'] . '&api_key=' . $this->config['jellyfinToken'] . ($showPlayed ? '' : '&IsPlayed=false') . '&Fields=Overview,People,Genres,CriticRating,Studios,Taglines';
+			} else {
+				$this->setAPIResponse('error', 'Jellyfin Error Occurred', 500);
+				return false;
+			}
+			$response = Requests::get($url, array(), $options);
+			if ($response->success) {
+				$items = array();
+				$jellyfin = json_decode($response->body, true);
+				if (isset($jellyfin['NowPlayingItem']) || isset($jellyfin['Name'])) {
+					$items[] = $this->resolveJellyfinItem($jellyfin);
+				}
+				$api['content'] = array_filter($items);
+				$this->setAPIResponse('success', null, 200, $api);
+				return $api;
+			} else {
+				$this->setAPIResponse('error', 'Jellyfin Error Occurred', 500);
+				return false;
+			}
+		} catch (Requests_Exception $e) {
+			$this->writeLog('error', 'Jellyfin Connect Function - Error: ' . $e->getMessage(), 'SYSTEM');
+			$this->setAPIResponse('error', $e->getMessage(), 500);
+			return false;
+		}
+	}
+	
 	public function resolveJellyfinItem($itemDetails)
 	{
 		$item = isset($itemDetails['NowPlayingItem']['Id']) ? $itemDetails['NowPlayingItem'] : $itemDetails;
@@ -305,8 +546,11 @@ trait JellyfinHomepageItem
 		$jellyfinItem['user'] = ($this->config['homepageShowStreamNames'] && $this->qualifyRequest($this->config['homepageShowStreamNamesAuth'])) ? @(string)$itemDetails['UserName'] : "";
 		$jellyfinItem['userThumb'] = '';
 		$jellyfinItem['userAddress'] = (isset($itemDetails['RemoteEndPoint']) ? $itemDetails['RemoteEndPoint'] : "x.x.x.x");
-		$jellyfinURL = $this->config['jellyfinURL'] . '/web/index.html#!/itemdetails.html?id=';
-		$jellyfinItem['address'] = $this->config['jellyfinTabURL'] ? rtrim($this->config['jellyfinTabURL'], '/') . "/web/#!/item/item.html?id=" . $jellyfinItem['uid'] : $jellyfinURL . $jellyfinItem['uid'] . "&serverId=" . $jellyfinItem['id'];
+		$jellfinVariablesForLink = [
+			'{id}' => $jellyfinItem['uid'],
+			'{serverId}' => $jellyfinItem['id']
+		];
+		$jellyfinItem['address'] = $this->userDefinedIdReplacementLink($this->config['homepageJellyfinLink'], $jellfinVariablesForLink);
 		$jellyfinItem['nowPlayingOriginalImage'] = 'api/v2/homepage/image?source=jellyfin&type=' . $jellyfinItem['nowPlayingImageType'] . '&img=' . $jellyfinItem['nowPlayingThumb'] . '&height=' . $nowPlayingHeight . '&width=' . $nowPlayingWidth . '&key=' . $jellyfinItem['nowPlayingKey'] . '$' . $this->randString();
 		$jellyfinItem['originalImage'] = 'api/v2/homepage/image?source=jellyfin&type=' . $jellyfinItem['imageType'] . '&img=' . $jellyfinItem['thumb'] . '&height=' . $height . '&width=' . $width . '&key=' . $jellyfinItem['key'] . '$' . $this->randString();
 		$jellyfinItem['openTab'] = $this->config['jellyfinTabURL'] && $this->config['jellyfinTabName'] ? true : false;
@@ -393,208 +637,4 @@ trait JellyfinHomepageItem
 		return $jellyfinItem;
 	}
 	
-	public function getJellyfinHomepageStreams()
-	{
-		if (!$this->config['homepageJellyfinEnabled']) {
-			$this->setAPIResponse('error', 'Jellyfin homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->config['homepageJellyfinStreams']) {
-			$this->setAPIResponse('error', 'Jellyfin homepage module is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageJellyfinAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageJellyStreamsAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage module', 401);
-			return false;
-		}
-		if (empty($this->config['jellyfinURL'])) {
-			$this->setAPIResponse('error', 'Jellyfin URL is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['jellyfinToken'])) {
-			$this->setAPIResponse('error', 'Jellyfin Token is not defined', 422);
-			return false;
-		}
-		$url = $this->qualifyURL($this->config['jellyfinURL']);
-		$url = $url . '/Sessions?api_key=' . $this->config['jellyfinToken'] . '&Fields=Overview,People,Genres,CriticRating,Studios,Taglines';
-		$options = ($this->localURL($url)) ? array('verify' => false) : array();
-		try {
-			$response = Requests::get($url, array(), $options);
-			if ($response->success) {
-				$items = array();
-				$jellyfin = json_decode($response->body, true);
-				foreach ($jellyfin as $child) {
-					if (isset($child['NowPlayingItem']) || isset($child['Name'])) {
-						$items[] = $this->resolveJellyfinItem($child);
-					}
-				}
-				$api['content'] = array_filter($items);
-				$this->setAPIResponse('success', null, 200, $api);
-				return $api;
-			} else {
-				$this->setAPIResponse('error', 'Jellyfin Error Occurred', 500);
-				return false;
-			}
-		} catch (Requests_Exception $e) {
-			$this->writeLog('error', 'Jellyfin Connect Function - Error: ' . $e->getMessage(), 'SYSTEM');
-			$this->setAPIResponse('error', $e->getMessage(), 500);
-			return false;
-		}
-	}
-	
-	public function getJellyfinHomepageRecent()
-	{
-		if (!$this->config['homepageJellyfinEnabled']) {
-			$this->setAPIResponse('error', 'Jellyfin homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->config['homepageJellyfinRecent']) {
-			$this->setAPIResponse('error', 'Jellyfin homepage module is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageJellyfinAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageJellyfinRecentAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage module', 401);
-			return false;
-		}
-		if (empty($this->config['jellyfinURL'])) {
-			$this->setAPIResponse('error', 'Jellyfin URL is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['jellyfinToken'])) {
-			$this->setAPIResponse('error', 'Jellyfin Token is not defined', 422);
-			return false;
-		}
-		$url = $this->qualifyURL($this->config['jellyfinURL']);
-		$options = ($this->localURL($url)) ? array('verify' => false) : array();
-		$username = false;
-		$showPlayed = false;
-		$userId = 0;
-		try {
-			if (isset($this->user['username'])) {
-				$username = strtolower($this->user['username']);
-			}
-			// Get A User
-			$userIds = $url . "/Users?api_key=" . $this->config['jellyfinToken'];
-			$response = Requests::get($userIds, array(), $options);
-			if ($response->success) {
-				$jellyfin = json_decode($response->body, true);
-				foreach ($jellyfin as $value) { // Scan for admin user
-					if (isset($value['Policy']) && isset($value['Policy']['IsAdministrator']) && $value['Policy']['IsAdministrator']) {
-						$userId = $value['Id'];
-					}
-					if ($username && strtolower($value['Name']) == $username) {
-						$userId = $value['Id'];
-						$showPlayed = false;
-						break;
-					}
-				}
-				$url = $url . '/Users/' . $userId . '/Items/Latest?EnableImages=true&Limit=' . $this->config['homepageRecentLimit'] . '&api_key=' . $this->config['jellyfinToken'] . ($showPlayed ? '' : '&IsPlayed=false') . '&Fields=Overview,People,Genres,CriticRating,Studios,Taglines';
-			} else {
-				$this->setAPIResponse('error', 'Jellyfin Error Occurred', 500);
-				return false;
-			}
-			$response = Requests::get($url, array(), $options);
-			if ($response->success) {
-				$items = array();
-				$jellyfin = json_decode($response->body, true);
-				foreach ($jellyfin as $child) {
-					if (isset($child['NowPlayingItem']) || isset($child['Name'])) {
-						$items[] = $this->resolveJellyfinItem($child);
-					}
-				}
-				$api['content'] = array_filter($items);
-				$this->setAPIResponse('success', null, 200, $api);
-				return $api;
-			} else {
-				$this->setAPIResponse('error', 'Jellyfin Error Occurred', 500);
-				return false;
-			}
-		} catch (Requests_Exception $e) {
-			$this->writeLog('error', 'Jellyfin Connect Function - Error: ' . $e->getMessage(), 'SYSTEM');
-			$this->setAPIResponse('error', $e->getMessage(), 500);
-			return false;
-		}
-	}
-	
-	public function getJellyfinHomepageMetadata($array)
-	{
-		if (!$this->config['homepageJellyfinEnabled']) {
-			$this->setAPIResponse('error', 'Jellyfin homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageJellyfinAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (empty($this->config['jellyfinURL'])) {
-			$this->setAPIResponse('error', 'Jellyfin URL is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['jellyfinToken'])) {
-			$this->setAPIResponse('error', 'Jellyfin Token is not defined', 422);
-			return false;
-		}
-		$key = $array['key'] ?? null;
-		if (!$key) {
-			$this->setAPIResponse('error', 'Jellyfin Metadata key is not defined', 422);
-			return false;
-		}
-		$url = $this->qualifyURL($this->config['jellyfinURL']);
-		$options = ($this->localURL($url)) ? array('verify' => false) : array();
-		$username = false;
-		$showPlayed = false;
-		$userId = 0;
-		try {
-			if (isset($this->user['username'])) {
-				$username = strtolower($this->user['username']);
-			}
-			// Get A User
-			$userIds = $url . "/Users?api_key=" . $this->config['jellyfinToken'];
-			$response = Requests::get($userIds, array(), $options);
-			if ($response->success) {
-				$jellyfin = json_decode($response->body, true);
-				foreach ($jellyfin as $value) { // Scan for admin user
-					if (isset($value['Policy']) && isset($value['Policy']['IsAdministrator']) && $value['Policy']['IsAdministrator']) {
-						$userId = $value['Id'];
-					}
-					if ($username && strtolower($value['Name']) == $username) {
-						$userId = $value['Id'];
-						$showPlayed = false;
-						break;
-					}
-				}
-				$url = $url . '/Users/' . $userId . '/Items/' . $key . '?EnableImages=true&Limit=' . $this->config['homepageRecentLimit'] . '&api_key=' . $this->config['jellyfinToken'] . ($showPlayed ? '' : '&IsPlayed=false') . '&Fields=Overview,People,Genres,CriticRating,Studios,Taglines';
-			} else {
-				$this->setAPIResponse('error', 'Jellyfin Error Occurred', 500);
-				return false;
-			}
-			$response = Requests::get($url, array(), $options);
-			if ($response->success) {
-				$items = array();
-				$jellyfin = json_decode($response->body, true);
-				if (isset($jellyfin['NowPlayingItem']) || isset($jellyfin['Name'])) {
-					$items[] = $this->resolveJellyfinItem($jellyfin);
-				}
-				$api['content'] = array_filter($items);
-				$this->setAPIResponse('success', null, 200, $api);
-				return $api;
-			} else {
-				$this->setAPIResponse('error', 'Jellyfin Error Occurred', 500);
-				return false;
-			}
-		} catch (Requests_Exception $e) {
-			$this->writeLog('error', 'Jellyfin Connect Function - Error: ' . $e->getMessage(), 'SYSTEM');
-			$this->setAPIResponse('error', $e->getMessage(), 500);
-			return false;
-		}
-	}
-	
 }

+ 45 - 40
api/homepage/lidarr.php

@@ -162,8 +162,8 @@ trait LidarrHomepageItem
 		$list = $this->csvHomepageUrlToken($this->config['lidarrURL'], $this->config['lidarrToken']);
 		foreach ($list as $key => $value) {
 			try {
-				$downloader = new Kryptonit3\Sonarr\Sonarr($value['url'], $value['token'], true);
-				$results = $downloader->getSystemStatus();
+				$downloader = new Kryptonit3\Sonarr\Sonarr($value['url'], $value['token'], 'lidarr');
+				$results = $downloader->getRootFolder();
 				$downloadList = json_decode($results, true);
 				if (is_array($downloadList) || is_object($downloadList)) {
 					$queue = (array_key_exists('error', $downloadList)) ? $downloadList['error']['msg'] : $downloadList;
@@ -194,37 +194,55 @@ trait LidarrHomepageItem
 		}
 	}
 	
-	public function getLidarrQueue()
+	public function lidarrHomepagePermissions($key = null)
 	{
-		if (!$this->config['homepageLidarrEnabled']) {
-			$this->setAPIResponse('error', 'Lidarr homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->config['homepageLidarrQueueEnabled']) {
-			$this->setAPIResponse('error', 'Lidarr homepage module is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageLidarrAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageLidarrQueueAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage module', 401);
-			return false;
-		}
-		if (empty($this->config['lidarrURL'])) {
-			$this->setAPIResponse('error', 'Lidarr URL is not defined', 422);
-			return false;
+		$permissions = [
+			'calendar' => [
+				'enabled' => [
+					'homepageLidarrEnabled'
+				],
+				'auth' => [
+					'homepageLidarrAuth'
+				],
+				'not_empty' => [
+					'lidarrURL',
+					'lidarrToken'
+				]
+			],
+			'queue' => [
+				'enabled' => [
+					'homepageLidarrEnabled',
+					'homepageLidarrQueueEnabled'
+				],
+				'auth' => [
+					'homepageLidarrAuth',
+					'homepageLidarrQueueAuth'
+				],
+				'not_empty' => [
+					'lidarrURL',
+					'lidarrToken'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
 		}
-		if (empty($this->config['lidarrToken'])) {
-			$this->setAPIResponse('error', 'Lidarr Token is not defined', 422);
+	}
+	
+	public function getLidarrQueue()
+	{
+		if (!$this->homepageItemPermissions($this->lidarrHomepagePermissions('queue'), true)) {
 			return false;
 		}
 		$queueItems = array();
 		$list = $this->csvHomepageUrlToken($this->config['lidarrURL'], $this->config['lidarrToken']);
 		foreach ($list as $key => $value) {
 			try {
-				$downloader = new Kryptonit3\Sonarr\Sonarr($value['url'], $value['token'], true);
+				$downloader = new Kryptonit3\Sonarr\Sonarr($value['url'], $value['token'], 'lidarr');
 				$results = $downloader->getQueue();
 				$downloadList = json_decode($results, true);
 				if (is_array($downloadList) || is_object($downloadList)) {
@@ -250,27 +268,14 @@ trait LidarrHomepageItem
 	{
 		$startDate = ($startDate) ?? $_GET['start'];
 		$endDate = ($endDate) ?? $_GET['end'];
-		if (!$this->config['homepageLidarrEnabled']) {
-			$this->setAPIResponse('error', 'Lidarr homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageLidarrAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (empty($this->config['lidarrURL'])) {
-			$this->setAPIResponse('error', 'Lidarr URL is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['lidarrToken'])) {
-			$this->setAPIResponse('error', 'Lidarr Token is not defined', 422);
+		if (!$this->homepageItemPermissions($this->lidarrHomepagePermissions('calendar'), true)) {
 			return false;
 		}
 		$calendarItems = array();
 		$list = $this->csvHomepageUrlToken($this->config['lidarrURL'], $this->config['lidarrToken']);
 		foreach ($list as $key => $value) {
 			try {
-				$downloader = new Kryptonit3\Sonarr\Sonarr($value['url'], $value['token'], true);
+				$downloader = new Kryptonit3\Sonarr\Sonarr($value['url'], $value['token'], 'lidarr');
 				$results = $downloader->getCalendar($startDate, $endDate);
 				$result = json_decode($results, true);
 				if (is_array($result) || is_object($result)) {

+ 32 - 0
api/homepage/misc.php

@@ -0,0 +1,32 @@
+<?php
+
+trait MiscHomepageItem
+{
+	public function miscSettingsArray()
+	{
+		return array(
+			'name' => 'Misc',
+			'enabled' => true,
+			'image' => 'plugins/images/organizr/logo-no-border.png',
+			'category' => 'Custom',
+			'settings' => array(
+				'YouTube' => array(
+					array(
+						'type' => 'input',
+						'name' => 'youtubeAPI',
+						'label' => 'Youtube API Key',
+						'value' => $this->config['youtubeAPI'],
+						'help' => 'Please make sure to input this API key as the organizr one gets limited'
+					),
+					array(
+						'type' => 'html',
+						'override' => 6,
+						'label' => 'Instructions',
+						'html' => '<a href="https://www.slickremix.com/docs/get-api-key-for-youtube/" target="_blank">Click here for instructions</a>'
+					),
+				)
+			)
+		);
+	}
+	
+}

+ 61 - 31
api/homepage/monitorr.php

@@ -69,53 +69,82 @@ trait MonitorrHomepageItem
 		);
 	}
 	
-	public function getMonitorrHomepageData()
+	public function monitorrHomepagePermissions($key = null)
 	{
-		if (!$this->config['homepageMonitorrEnabled']) {
-			$this->setAPIResponse('error', 'Monitorr homepage item is not enabled', 409);
-			return false;
+		$permissions = [
+			'main' => [
+				'enabled' => [
+					'homepageMonitorrEnabled'
+				],
+				'auth' => [
+					'homepageMonitorrAuth'
+				],
+				'not_empty' => [
+					'monitorrURL'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
 		}
-		if (!$this->qualifyRequest($this->config['homepageMonitorrAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
+	}
+	
+	public function homepageOrderMonitorr()
+	{
+		if ($this->homepageItemPermissions($this->monitorrHomepagePermissions('main'))) {
+			return '
+				<div id="' . __FUNCTION__ . '">
+					<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Monitorr...</h2></div>
+					<script>
+						// Monitorr
+						homepageMonitorr("' . $this->config['homepageMonitorrRefresh'] . '");
+						// End Monitorr
+					</script>
+				</div>
+				';
 		}
-		if (empty($this->config['monitorrURL'])) {
-			$this->setAPIResponse('error', 'Monitorr URL is not defined', 422);
+	}
+	
+	public function getMonitorrHomepageData()
+	{
+		if (!$this->homepageItemPermissions($this->monitorrHomepagePermissions('main'), true)) {
 			return false;
 		}
 		$api = [];
 		$url = $this->qualifyURL($this->config['monitorrURL']);
 		$dataUrl = $url . '/assets/php/loop.php';
 		try {
-			$response = Requests::get($dataUrl, ['Token' => $this->config['organizrAPI']], []);
+			$options = $this->requestOptions($this->config['monitorrURL'], false, $this->config['homepageMonitorrRefresh']);
+			$response = Requests::get($dataUrl, ['Token' => $this->config['organizrAPI']], $options);
 			if ($response->success) {
 				$html = html_entity_decode($response->body);
 				// This section grabs the names of all services by regex
 				$services = [];
 				$servicesMatch = [];
-				$servicePattern = '/<div id="servicetitle"><div>(.*)<\/div><\/div><div class="btnonline">Online<\/div><\/a><\/div><\/div>|<div id="servicetitleoffline".*><div>(.*)<\/div><\/div><div class="btnoffline".*>Offline<\/div><\/div><\/div>|<div id="servicetitlenolink".*><div>(.*)<\/div><\/div><div class="btnonline".*>Online<\/div><\/div><\/div>|<div id="servicetitle"><div>(.*)<\/div><\/div><div class="btnunknown">/';
+				$servicePattern = '/<div id="servicetitle(?:offline|nolink)?".*><div>(.*)<\/div><\/div><div class="(?:btnonline|btnoffline|btnunknown)".*>(Online|Offline|Unresponsive)<\/div>(:?<\/a>)?<\/div><\/div>/';
 				preg_match_all($servicePattern, $html, $servicesMatch);
-				$services = array_filter($servicesMatch[1]) + array_filter($servicesMatch[2]) + array_filter($servicesMatch[3]) + array_filter($servicesMatch[4]);
+				$services = array_filter($servicesMatch[1]);
+				$status = array_filter($servicesMatch[2]);
 				$statuses = [];
 				foreach ($services as $key => $service) {
-					$statusPattern = '/' . $service . '<\/div><\/div><div class="btnonline">(Online)<\/div>|' . $service . '<\/div><\/div><div class="btnoffline".*>(Offline)<\/div><\/div><\/div>|' . $service . '<\/div><\/div><div class="btnunknown">(.*)<\/div><\/a>/';
-					$status = [];
-					preg_match($statusPattern, $html, $status);
-					$statuses[$service] = $status;
-					foreach ($status as $match) {
-						if ($match == 'Online') {
-							$statuses[$service] = [
-								'status' => true
-							];
-						} else if ($match == 'Offline') {
-							$statuses[$service] = [
-								'status' => false
-							];
-						} else if ($match == 'Unresponsive') {
-							$statuses[$service] = [
-								'status' => 'unresponsive'
-							];
-						}
+					$match = $status[$key];
+					$statuses[$service] = $match;
+					if ($match == 'Online') {
+						$statuses[$service] = [
+							'status' => true
+						];
+					} else if ($match == 'Offline') {
+						$statuses[$service] = [
+							'status' => false
+						];
+					} else if ($match == 'Unresponsive') {
+						$statuses[$service] = [
+							'status' => 'unresponsive'
+						];
 					}
 					$statuses[$service]['sort'] = $key;
 					$imageMatch = [];
@@ -133,7 +162,8 @@ trait MonitorrHomepageItem
 					$ext = $ext[key(array_slice($ext, -1, 1, true))];
 					$imageUrl = $url . '/assets' . $image;
 					$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
-					$img = Requests::get($imageUrl, ['Token' => $this->config['organizrAPI']], []);
+					$options = $this->requestOptions($this->config['monitorrURL'], false, $this->config['homepageMonitorrRefresh']);
+					$img = Requests::get($imageUrl, ['Token' => $this->config['organizrAPI']], $options);
 					if ($img->success) {
 						$base64 = 'data:image/' . $ext . ';base64,' . base64_encode($img->body);
 						$statuses[$service]['image'] = $base64;

+ 41 - 10
api/homepage/netdata.php

@@ -4,16 +4,7 @@ trait NetDataHomepageItem
 {
 	public function getNetdataHomepageData()
 	{
-		if (!$this->config['homepageNetdataEnabled']) {
-			$this->setAPIResponse('error', 'NetData homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageNetdataAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (empty($this->config['netdataURL'])) {
-			$this->setAPIResponse('error', 'NetData URL is not defined', 422);
+		if (!$this->homepageItemPermissions($this->netdataHomepagePermissions('main'), true)) {
 			return false;
 		}
 		$api = [];
@@ -293,6 +284,46 @@ trait NetDataHomepageItem
 		return $array;
 	}
 	
+	public function netdataHomepagePermissions($key = null)
+	{
+		$permissions = [
+			'main' => [
+				'enabled' => [
+					'homepageNetdataEnabled'
+				],
+				'auth' => [
+					'homepageNetdataAuth'
+				],
+				'not_empty' => [
+					'netdataURL'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
+		}
+	}
+	
+	public function homepageOrderNetdata()
+	{
+		if ($this->homepageItemPermissions($this->netdataHomepagePermissions('main'))) {
+			return '
+				<div id="' . __FUNCTION__ . '">
+					<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Netdata...</h2></div>
+					<script>
+						// Netdata
+						homepageNetdata("' . $this->config['homepageNetdataRefresh'] . '");
+						// End Netdata
+					</script>
+				</div>
+				';
+		}
+	}
+	
 	public function disk($dimension, $url)
 	{
 		$data = [];

+ 43 - 9
api/homepage/nzbget.php

@@ -109,18 +109,52 @@ trait NZBGetHomepageItem
 		}
 	}
 	
-	public function getNzbgetHomepageQueue()
+	public function nzbgetHomepagePermissions($key = null)
 	{
-		if (!$this->config['homepageNzbgetEnabled']) {
-			$this->setAPIResponse('error', 'NZBGet homepage item is not enabled', 409);
-			return false;
+		$permissions = [
+			'main' => [
+				'enabled' => [
+					'homepageNzbgetEnabled'
+				],
+				'auth' => [
+					'homepageNzbgetAuth'
+				],
+				'not_empty' => [
+					'nzbgetURL'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
 		}
-		if (!$this->qualifyRequest($this->config['homepageNzbgetAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
+	}
+	
+	public function homepageOrdernzbget()
+	{
+		if ($this->homepageItemPermissions($this->nzbgetHomepagePermissions('main'))) {
+			$loadingBox = ($this->config['nzbgetCombine']) ? '' : '<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Download Queue...</h2></div>';
+			$builder = ($this->config['nzbgetCombine']) ? 'buildDownloaderCombined(\'nzbget\');' : '$("#' . __FUNCTION__ . '").html(buildDownloader("nzbget"));';
+			return '
+				<div id="' . __FUNCTION__ . '">
+					' . $loadingBox . '
+					<script>
+		                // homepageOrdernzbget
+		                ' . $builder . '
+		                homepageDownloader("nzbget", "' . $this->config['homepageDownloadRefresh'] . '");
+		                // End homepageOrdernzbget
+	                </script>
+				</div>
+				';
 		}
-		if (empty($this->config['nzbgetURL'])) {
-			$this->setAPIResponse('error', 'NZBGet URL is not defined', 422);
+	}
+	
+	public function getNzbgetHomepageQueue()
+	{
+		if (!$this->homepageItemPermissions($this->nzbgetHomepagePermissions('main'), true)) {
 			return false;
 		}
 		try {

+ 41 - 13
api/homepage/octoprint.php

@@ -61,22 +61,50 @@ trait OctoPrintHomepageItem
 		);
 	}
 	
-	public function getOctoprintHomepageData()
+	public function octoprintHomepagePermissions($key = null)
 	{
-		if (!$this->config['homepageOctoprintEnabled']) {
-			$this->setAPIResponse('error', 'OctoPrint homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageOctoprintAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
+		$permissions = [
+			'main' => [
+				'enabled' => [
+					'homepageOctoprintEnabled'
+				],
+				'auth' => [
+					'homepageOctoprintAuth'
+				],
+				'not_empty' => [
+					'octoprintURL',
+					'octoprintToken'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
 		}
-		if (empty($this->config['octoprintURL'])) {
-			$this->setAPIResponse('error', 'OctoPrint URL is not defined', 422);
-			return false;
+	}
+	
+	public function homepageOrderOctoprint()
+	{
+		if ($this->homepageItemPermissions($this->octoprintHomepagePermissions('main'))) {
+			return '
+				<div id="' . __FUNCTION__ . '">
+					<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading OctoPrint...</h2></div>
+					<script>
+						// Octoprint
+						homepageOctoprint("' . $this->config['homepageOctoprintRefresh'] . '");
+						// End Octoprint
+					</script>
+				</div>
+				';
 		}
-		if (empty($this->config['octoprintToken'])) {
-			$this->setAPIResponse('error', 'OctoPrint Token is not defined', 422);
+	}
+	
+	public function getOctoprintHomepageData()
+	{
+		if (!$this->homepageItemPermissions($this->octoprintHomepagePermissions('main'), true)) {
 			return false;
 		}
 		$api = [];

+ 62 - 50
api/homepage/ombi.php

@@ -40,7 +40,20 @@ trait OmbiHomepageItem
 						'name' => 'ombiToken',
 						'label' => 'Token',
 						'value' => $this->config['ombiToken']
-					)
+					),
+					array(
+						'type' => 'input',
+						'name' => 'ombiFallbackUser',
+						'label' => 'Ombi Fallback User',
+						'value' => $this->config['ombiFallbackUser'],
+						'help' => 'Organizr will request an Ombi User Token based off of this user credentials'
+					),
+					array(
+						'type' => 'password-alt',
+						'name' => 'ombiFallbackPassword',
+						'label' => 'Ombi Fallback Password',
+						'value' => $this->config['ombiFallbackPassword']
+					),
 				),
 				'Misc Options' => array(
 					array(
@@ -169,27 +182,51 @@ trait OmbiHomepageItem
 		};
 	}
 	
-	public function ombiTVDefault($type)
+	public function ombiHomepagePermissions($key = null)
 	{
-		return $type == $this->config['ombiTvDefault'];
+		$permissions = [
+			'main' => [
+				'enabled' => [
+					'homepageOmbiEnabled'
+				],
+				'auth' => [
+					'homepageOmbiAuth'
+				],
+				'not_empty' => [
+					'ombiURL',
+					'ombiToken'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
+		}
 	}
 	
-	public function getOmbiRequests($type = "both", $limit = 50, $offset = 0)
+	public function homepageOrderombi()
 	{
-		if (!$this->config['homepageOmbiEnabled']) {
-			$this->setAPIResponse('error', 'Ombi homepage item is not enabled', 409);
-			return false;
+		if ($this->homepageItemPermissions($this->ombiHomepagePermissions('main'))) {
+			return '
+				<div id="' . __FUNCTION__ . '">
+					<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Requests...</h2></div>
+					<script>
+						// Ombi Requests
+						homepageRequests("' . $this->config['ombiRefresh'] . '");
+						// End Ombi Requests
+					</script>
+				</div>
+				';
 		}
-		if (!$this->qualifyRequest($this->config['homepageOmbiAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (empty($this->config['ombiURL'])) {
-			$this->setAPIResponse('error', 'Ombi URL is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['ombiToken'])) {
-			$this->setAPIResponse('error', 'Ombi Token is not defined', 422);
+	}
+	
+	
+	public function getOmbiRequests($type = "both", $limit = 50, $offset = 0)
+	{
+		if (!$this->homepageItemPermissions($this->ombiHomepagePermissions('main'), true)) {
 			return false;
 		}
 		$api['count'] = array(
@@ -223,7 +260,7 @@ trait OmbiHomepageItem
 					$movie = json_decode($movie->body, true);
 					//$movie = array_reverse($movie);
 					foreach ($movie as $key => $value) {
-						$proceed = (($this->config['ombiLimitUser']) && strtolower($this->user['username']) == strtolower($value['requestedUser']['userName'])) || (!$this->config['ombiLimitUser']) || $this->qualifyRequest(1);
+						$proceed = (($this->config['ombiLimitUser']) && strtolower($this->user['username']) == strtolower($value['requestedUser']['userName'])) || (strtolower($value['requestedUser']['userName']) == strtolower($this->config['ombiFallbackUser'])) || (!$this->config['ombiLimitUser']) || $this->qualifyRequest(1);
 						if ($proceed) {
 							$api['count']['movie']++;
 							$requests[] = array(
@@ -308,20 +345,7 @@ trait OmbiHomepageItem
 			$this->setAPIResponse('error', 'Type was not supplied', 422);
 			return false;
 		}
-		if (!$this->config['homepageOmbiEnabled']) {
-			$this->setAPIResponse('error', 'Ombi homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageOmbiAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (empty($this->config['ombiURL'])) {
-			$this->setAPIResponse('error', 'Ombi URL is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['ombiToken'])) {
-			$this->setAPIResponse('error', 'Ombi Token is not defined', 422);
+		if (!$this->homepageItemPermissions($this->ombiHomepagePermissions('main'), true)) {
 			return false;
 		}
 		$url = $this->qualifyURL($this->config['ombiURL']);
@@ -449,24 +473,7 @@ trait OmbiHomepageItem
 			$this->setAPIResponse('error', 'Action was not supplied', 422);
 			return false;
 		}
-		if (!$this->config['homepageOmbiEnabled']) {
-			$this->setAPIResponse('error', 'Ombi homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageOmbiAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (!$this->qualifyRequest(1)) {
-			$this->setAPIResponse('error', 'User must be an admin', 401);
-			return false;
-		}
-		if (empty($this->config['ombiURL'])) {
-			$this->setAPIResponse('error', 'Ombi URL is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['ombiToken'])) {
-			$this->setAPIResponse('error', 'Ombi Token is not defined', 422);
+		if (!$this->homepageItemPermissions($this->ombiHomepagePermissions('main'), true)) {
 			return false;
 		}
 		$url = $this->qualifyURL($this->config['ombiURL']);
@@ -526,4 +533,9 @@ trait OmbiHomepageItem
 			return false;
 		};
 	}
+	
+	public function ombiTVDefault($type)
+	{
+		return $type == $this->config['ombiTvDefault'];
+	}
 }

+ 40 - 9
api/homepage/pihole.php

@@ -112,18 +112,49 @@ trait PiHoleHomepageItem
 		}
 	}
 	
-	public function getPiholeHomepageStats()
+	public function piholeHomepagePermissions($key = null)
 	{
-		if (!$this->config['homepagePiholeEnabled']) {
-			$this->setAPIResponse('error', 'Pihole homepage item is not enabled', 409);
-			return false;
+		$permissions = [
+			'main' => [
+				'enabled' => [
+					'homepagePiholeEnabled'
+				],
+				'auth' => [
+					'homepagePiholeAuth'
+				],
+				'not_empty' => [
+					'piholeURL'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
 		}
-		if (!$this->qualifyRequest($this->config['homepagePiholeAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
+	}
+	
+	public function homepageOrderPihole()
+	{
+		if ($this->homepageItemPermissions($this->piholeHomepagePermissions('main'))) {
+			return '
+				<div id="' . __FUNCTION__ . '">
+					<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Pihole...</h2></div>
+					<script>
+						// Pi-hole Stats
+						homepagePihole("' . $this->config['homepagePiholeRefresh'] . '");
+						// End Pi-hole Stats
+					</script>
+				</div>
+				';
 		}
-		if (empty($this->config['piholeURL'])) {
-			$this->setAPIResponse('error', 'Pihole URL is not defined', 422);
+	}
+	
+	public function getPiholeHomepageStats()
+	{
+		if (!$this->homepageItemPermissions($this->piholeHomepagePermissions('main'), true)) {
 			return false;
 		}
 		$api = array();

+ 451 - 285
api/homepage/plex.php

@@ -5,6 +5,23 @@ trait PlexHomepageItem
 	
 	public function plexSettingsArray()
 	{
+		if ($this->config['plexID'] !== '' && $this->config['plexToken'] !== '') {
+			$loop = $this->plexLibraryList('key')['libraries'];
+			foreach ($loop as $key => $value) {
+				$libraryList[] = array(
+					'name' => $key,
+					'value' => $value
+				);
+			}
+		} else {
+			$libraryList = array(
+				array(
+					'name' => 'Refresh page to update List',
+					'value' => '',
+					'disabled' => true,
+				),
+			);
+		}
 		return array(
 			'name' => 'Plex',
 			'enabled' => strpos('personal', $this->config['license']) !== false,
@@ -35,18 +52,37 @@ trait PlexHomepageItem
 						'help' => 'Please make sure to use local IP address and port - You also may use local dns name too.',
 						'placeholder' => 'http(s)://hostname:port'
 					),
+					array(
+						'type' => 'blank',
+						'name' => '',
+						'label' => '',
+					),
 					array(
 						'type' => 'password-alt',
 						'name' => 'plexToken',
 						'label' => 'Token',
 						'value' => $this->config['plexToken']
 					),
+					array(
+						'type' => 'button',
+						'label' => 'Get Plex Token',
+						'icon' => 'fa fa-ticket',
+						'text' => 'Retrieve',
+						'attr' => 'onclick="showPlexTokenForm(\'#homepage-Plex-form [name=plexToken]\')"'
+					),
 					array(
 						'type' => 'password-alt',
 						'name' => 'plexID',
 						'label' => 'Plex Machine',
 						'value' => $this->config['plexID']
-					)
+					),
+					array(
+						'type' => 'button',
+						'label' => 'Get Plex Machine',
+						'icon' => 'fa fa-id-badge',
+						'text' => 'Retrieve',
+						'attr' => 'onclick="showPlexMachineForm(\'#homepage-Plex-form [name=plexID]\')"'
+					),
 				),
 				'Active Streams' => array(
 					array(
@@ -68,6 +104,15 @@ trait PlexHomepageItem
 						'label' => 'User Information',
 						'value' => $this->config['homepageShowStreamNames']
 					),
+					array(
+						'type' => 'select2',
+						'class' => 'select2-multiple',
+						'id' => 'plex-stream-exclude-select',
+						'name' => 'homepagePlexStreamsExclude',
+						'label' => 'Libraries to Exclude',
+						'value' => $this->config['homepagePlexStreamsExclude'],
+						'options' => $libraryList
+					),
 					array(
 						'type' => 'select',
 						'name' => 'homepageShowStreamNamesAuth',
@@ -186,6 +231,39 @@ trait PlexHomepageItem
 								'value' => '3'
 							)
 						)
+					),
+					array(
+						'type' => 'blank',
+						'label' => ''
+					),
+					array(
+						'type' => 'switch',
+						'name' => 'homepageUseCustomStreamNames',
+						'label' => 'Use custom names for users',
+						'value' => $this->config['homepageUseCustomStreamNames']
+					),
+					array(
+						'type' => 'html',
+						'name' => 'grabFromTautulli',
+						'label' => 'Grab from Tautulli. (Note, you must have set the Tautulli API key already)',
+						'override' => 6,
+						'html' => '<button type="button" onclick="getTautulliFriendlyNames()" class="btn btn-sm btn-success btn-rounded waves-effect waves-light b-none">Grab Names</button>',
+					),
+					array(
+						'type' => 'html',
+						'name' => 'homepageCustomStreamNamesAce',
+						'class' => 'jsonTextarea hidden',
+						'label' => 'Custom definitions for user names (JSON Object, with the key being the plex name, and the value what you want to override with)',
+						'override' => 12,
+						'html' => '<div id="homepageCustomStreamNamesAce" style="height: 300px;">' . htmlentities($this->config['homepageCustomStreamNames']) . '</div>',
+					),
+					array(
+						'type' => 'textbox',
+						'name' => 'homepageCustomStreamNames',
+						'class' => 'jsonTextarea hidden',
+						'id' => 'homepageCustomStreamNamesText',
+						'label' => '',
+						'value' => $this->config['homepageCustomStreamNames'],
 					)
 				),
 				'Test Connection' => array(
@@ -209,7 +287,7 @@ trait PlexHomepageItem
 	public function testConnectionPlex()
 	{
 		if (!empty($this->config['plexURL']) && !empty($this->config['plexToken'])) {
-			$url = $this->qualifyURL($this->config['plexURL']) . "/?X-Plex-Token=" . $this->config['plexToken'];
+			$url = $this->qualifyURL($this->config['plexURL']) . "/servers?X-Plex-Token=" . $this->config['plexToken'];
 			try {
 				$options = ($this->localURL($url)) ? array('verify' => false) : array();
 				$response = Requests::get($url, array(), $options);
@@ -217,17 +295,335 @@ trait PlexHomepageItem
 				if ($response->success) {
 					$this->setAPIResponse('success', 'API Connection succeeded', 200);
 					return true;
+				} else {
+					$this->setAPIResponse('error', 'URL and/or Token not setup correctly', 422);
+					return false;
 				}
 			} catch (Requests_Exception $e) {
 				$this->setAPIResponse('error', $e->getMessage(), 500);
 				return false;
-			};
+			}
 		} else {
 			$this->setAPIResponse('error', 'URL and/or Token not setup', 422);
 			return 'URL and/or Token not setup';
 		}
 	}
 	
+	public function plexHomepagePermissions($key = null)
+	{
+		$permissions = [
+			'streams' => [
+				'enabled' => [
+					'homepagePlexEnabled',
+					'homepagePlexStreams'
+				],
+				'auth' => [
+					'homepagePlexAuth',
+					'homepagePlexStreamsAuth'
+				],
+				'not_empty' => [
+					'plexURL',
+					'plexToken',
+					'plexID'
+				]
+			],
+			'recent' => [
+				'enabled' => [
+					'homepagePlexEnabled',
+					'homepagePlexRecent'
+				],
+				'auth' => [
+					'homepagePlexAuth',
+					'homepagePlexRecentAuth'
+				],
+				'not_empty' => [
+					'plexURL',
+					'plexToken',
+					'plexID'
+				]
+			],
+			'playlists' => [
+				'enabled' => [
+					'homepagePlexEnabled',
+					'homepagePlexPlaylist'
+				],
+				'auth' => [
+					'homepagePlexAuth',
+					'homepagePlexPlaylistAuth'
+				],
+				'not_empty' => [
+					'plexURL',
+					'plexToken',
+					'plexID'
+				]
+			],
+			'metadata' => [
+				'enabled' => [
+					'homepagePlexEnabled'
+				],
+				'auth' => [
+					'homepagePlexAuth'
+				],
+				'not_empty' => [
+					'plexURL',
+					'plexToken',
+					'plexID'
+				]
+			],
+			'search' => [
+				'enabled' => [
+					'homepagePlexEnabled',
+					'mediaSearch'
+				],
+				'auth' => [
+					'homepagePlexAuth',
+					'mediaSearchAuth'
+				],
+				'not_empty' => [
+					'plexURL',
+					'plexToken',
+					'plexID'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
+		}
+	}
+	
+	public function homepageOrderplexnowplaying()
+	{
+		if ($this->homepageItemPermissions($this->plexHomepagePermissions('streams'))) {
+			return '
+				<div id="' . __FUNCTION__ . '">
+					<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Now Playing...</h2></div>
+					<script>
+						// Plex Stream
+						homepageStream("plex", "' . $this->config['homepageStreamRefresh'] . '");
+						// End Plex Stream
+					</script>
+				</div>
+				';
+		}
+	}
+	
+	public function homepageOrderplexrecent()
+	{
+		if ($this->homepageItemPermissions($this->plexHomepagePermissions('recent'))) {
+			return '
+				<div id="' . __FUNCTION__ . '">
+					<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Recent...</h2></div>
+					<script>
+						// Plex Recent
+						homepageRecent("plex", "' . $this->config['homepageRecentRefresh'] . '");
+						// End Plex Recent
+					</script>
+				</div>
+				';
+		}
+	}
+	
+	public function homepageOrderplexplaylist()
+	{
+		if ($this->homepageItemPermissions($this->plexHomepagePermissions('playlists'))) {
+			return '
+				<div id="' . __FUNCTION__ . '">
+					<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Playlists...</h2></div>
+					<script>
+						// Plex Playlist
+						homepagePlaylist("plex");
+						// End Plex Playlist
+					</script>
+				</div>
+				';
+		}
+	}
+	
+	public function getPlexHomepageStreams()
+	{
+		if (!$this->homepageItemPermissions($this->plexHomepagePermissions('streams'), true)) {
+			return false;
+		}
+		$ignore = array();
+		$exclude = explode(',', $this->config['homepagePlexStreamsExclude']);
+		$resolve = true;
+		$url = $this->qualifyURL($this->config['plexURL']);
+		$url = $url . "/status/sessions?X-Plex-Token=" . $this->config['plexToken'];
+		$options = ($this->localURL($url)) ? array('verify' => false) : array();
+		$response = Requests::get($url, array(), $options);
+		libxml_use_internal_errors(true);
+		if ($response->success) {
+			$items = array();
+			$plex = simplexml_load_string($response->body);
+			foreach ($plex as $child) {
+				if (!in_array($child['type'], $ignore) && !in_array($child['librarySectionID'], $exclude) && isset($child['librarySectionID'])) {
+					$items[] = $this->resolvePlexItem($child);
+				}
+			}
+			$api['content'] = ($resolve) ? $items : $plex;
+			$api['plexID'] = $this->config['plexID'];
+			$api['showNames'] = true;
+			$api['group'] = '1';
+			$this->setAPIResponse('success', null, 200, $api);
+			return $api;
+		}
+	}
+	
+	public function getPlexHomepageRecent()
+	{
+		if (!$this->homepageItemPermissions($this->plexHomepagePermissions('recent'), true)) {
+			return false;
+		}
+		$ignore = array();
+		$resolve = true;
+		$url = $this->qualifyURL($this->config['plexURL']);
+		$urls['movie'] = $url . "/hubs/home/recentlyAdded?X-Plex-Token=" . $this->config['plexToken'] . "&X-Plex-Container-Start=0&X-Plex-Container-Size=" . $this->config['homepageRecentLimit'] . "&type=1";
+		$urls['tv'] = $url . "/hubs/home/recentlyAdded?X-Plex-Token=" . $this->config['plexToken'] . "&X-Plex-Container-Start=0&X-Plex-Container-Size=" . $this->config['homepageRecentLimit'] . "&type=2";
+		$urls['music'] = $url . "/hubs/home/recentlyAdded?X-Plex-Token=" . $this->config['plexToken'] . "&X-Plex-Container-Start=0&X-Plex-Container-Size=" . $this->config['homepageRecentLimit'] . "&type=8";
+		foreach ($urls as $k => $v) {
+			$options = ($this->localURL($v)) ? array('verify' => false) : array();
+			$response = Requests::get($v, array(), $options);
+			libxml_use_internal_errors(true);
+			if ($response->success) {
+				$items = array();
+				$plex = simplexml_load_string($response->body);
+				foreach ($plex as $child) {
+					if (!in_array($child['type'], $ignore) && isset($child['librarySectionID'])) {
+						$items[] = $this->resolvePlexItem($child);
+					}
+				}
+				if (isset($api)) {
+					$api['content'] = array_merge($api['content'], ($resolve) ? $items : $plex);
+				} else {
+					$api['content'] = ($resolve) ? $items : $plex;
+				}
+			}
+		}
+		if (isset($api['content'])) {
+			usort($api['content'], function ($a, $b) {
+				return $b['addedAt'] <=> $a['addedAt'];
+			});
+		}
+		$api['plexID'] = $this->config['plexID'];
+		$api['showNames'] = true;
+		$api['group'] = '1';
+		$this->setAPIResponse('success', null, 200, $api);
+		return $api;
+	}
+	
+	public function getPlexHomepagePlaylists()
+	{
+		if (!$this->homepageItemPermissions($this->plexHomepagePermissions('playlists'), true)) {
+			return false;
+		}
+		$url = $this->qualifyURL($this->config['plexURL']);
+		$url = $url . "/playlists?X-Plex-Token=" . $this->config['plexToken'];
+		$options = ($this->localURL($url)) ? array('verify' => false) : array();
+		$response = Requests::get($url, array(), $options);
+		libxml_use_internal_errors(true);
+		if ($response->success) {
+			$items = array();
+			$plex = simplexml_load_string($response->body);
+			foreach ($plex as $child) {
+				if ($child['playlistType'] == "video" && strpos(strtolower($child['title']), 'private') === false) {
+					$playlistTitleClean = preg_replace("/(\W)+/", "", (string)$child['title']);
+					$playlistURL = $this->qualifyURL($this->config['plexURL']);
+					$playlistURL = $playlistURL . $child['key'] . "?X-Plex-Token=" . $this->config['plexToken'];
+					$options = ($this->localURL($url)) ? array('verify' => false) : array();
+					$playlistResponse = Requests::get($playlistURL, array(), $options);
+					if ($playlistResponse->success) {
+						$playlistResponse = simplexml_load_string($playlistResponse->body);
+						$items[$playlistTitleClean]['title'] = (string)$child['title'];
+						foreach ($playlistResponse->Video as $playlistItem) {
+							$items[$playlistTitleClean][] = $this->resolvePlexItem($playlistItem);
+						}
+					}
+				}
+			}
+			$api['content'] = $items;
+			$api['plexID'] = $this->config['plexID'];
+			$api['showNames'] = true;
+			$api['group'] = '1';
+			$this->setAPIResponse('success', null, 200, $api);
+			return $api;
+		} else {
+			$this->setAPIResponse('error', 'Plex API error', 500);
+			return false;
+		}
+	}
+	
+	public function getPlexHomepageMetadata($array)
+	{
+		if (!$this->homepageItemPermissions($this->plexHomepagePermissions('metadata'), true)) {
+			return false;
+		}
+		$key = $array['key'] ?? null;
+		if (!$key) {
+			$this->setAPIResponse('error', 'Plex Metadata key is not defined', 422);
+			return false;
+		}
+		$ignore = array();
+		$resolve = true;
+		$url = $this->qualifyURL($this->config['plexURL']);
+		$url = $url . "/library/metadata/" . $key . "?X-Plex-Token=" . $this->config['plexToken'];
+		$options = ($this->localURL($url)) ? array('verify' => false) : array();
+		$response = Requests::get($url, array(), $options);
+		libxml_use_internal_errors(true);
+		if ($response->success) {
+			$items = array();
+			$plex = simplexml_load_string($response->body);
+			foreach ($plex as $child) {
+				if (!in_array($child['type'], $ignore) && isset($child['librarySectionID'])) {
+					$items[] = $this->resolvePlexItem($child);
+				}
+			}
+			$api['content'] = ($resolve) ? $items : $plex;
+			$api['plexID'] = $this->config['plexID'];
+			$api['showNames'] = true;
+			$api['group'] = '1';
+			$this->setAPIResponse('success', null, 200, $api);
+			return $api;
+		}
+	}
+	
+	public function getPlexHomepageSearch($query)
+	{
+		if (!$this->homepageItemPermissions($this->plexHomepagePermissions('search'), true)) {
+			return false;
+		}
+		$query = $query ?? null;
+		if (!$query) {
+			$this->setAPIResponse('error', 'Plex Metadata key is not defined', 422);
+			return false;
+		}
+		$ignore = array('artist', 'episode');
+		$resolve = true;
+		$url = $this->qualifyURL($this->config['plexURL']);
+		$url = $url . "/search?query=" . rawurlencode($query) . "&X-Plex-Token=" . $this->config['plexToken'];
+		$options = ($this->localURL($url)) ? array('verify' => false) : array();
+		$response = Requests::get($url, array(), $options);
+		libxml_use_internal_errors(true);
+		if ($response->success) {
+			$items = array();
+			$plex = simplexml_load_string($response->body);
+			foreach ($plex as $child) {
+				if (!in_array($child['type'], $ignore) && isset($child['librarySectionID'])) {
+					$items[] = $this->resolvePlexItem($child);
+				}
+			}
+			$api['content'] = ($resolve) ? $items : $plex;
+			$api['plexID'] = $this->config['plexID'];
+			$api['showNames'] = true;
+			$api['group'] = '1';
+			$this->setAPIResponse('success', null, 200, $api);
+			return $api;
+		}
+	}
+	
 	public function resolvePlexItem($item)
 	{
 		// Static Height & Width
@@ -337,7 +733,7 @@ trait PlexHomepageItem
 		$plexItem['bandwidthType'] = (string)$item->Session['location'];
 		$plexItem['sessionType'] = isset($item->TranscodeSession['progress']) ? 'Transcoding' : 'Direct Playing';
 		$plexItem['state'] = (((string)$item->Player['state'] == "paused") ? "pause" : "play");
-		$plexItem['user'] = ($this->config['homepageShowStreamNames'] && $this->qualifyRequest($this->config['homepageShowStreamNamesAuth'])) ? (string)$item->User['title'] : "";
+		$plexItem['user'] = $this->formatPlexUserName($item);
 		$plexItem['userThumb'] = ($this->config['homepageShowStreamNames'] && $this->qualifyRequest($this->config['homepageShowStreamNamesAuth'])) ? (string)$item->User['thumb'] : "";
 		$plexItem['userAddress'] = ($this->config['homepageShowStreamNames'] && $this->qualifyRequest($this->config['homepageShowStreamNamesAuth'])) ? (string)$item->Player['address'] : "x.x.x.x";
 		$plexItem['address'] = $this->config['plexTabURL'] ? $this->config['plexTabURL'] . "/web/index.html#!/server/" . $this->config['plexID'] . "/details?key=/library/metadata/" . $item['ratingKey'] : "https://app.plex.tv/web/app#!/server/" . $this->config['plexID'] . "/details?key=/library/metadata/" . $item['ratingKey'];
@@ -421,301 +817,71 @@ trait PlexHomepageItem
 		return $plexItem;
 	}
 	
-	public function getPlexHomepageStreams()
+	public function getTautulliFriendlyNames()
 	{
-		if (!$this->config['homepagePlexEnabled']) {
-			$this->setAPIResponse('error', 'Plex homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->config['homepagePlexStreams']) {
-			$this->setAPIResponse('error', 'Plex homepage module is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepagePlexAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepagePlexStreamsAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage module', 401);
-			return false;
-		}
-		if (empty($this->config['plexURL'])) {
-			$this->setAPIResponse('error', 'Plex URL is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['plexToken'])) {
-			$this->setAPIResponse('error', 'Plex Token is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['plexID'])) {
-			$this->setAPIResponse('error', 'Plex Id is not defined', 422);
-			return false;
-		}
-		$ignore = array();
-		$resolve = true;
-		$url = $this->qualifyURL($this->config['plexURL']);
-		$url = $url . "/status/sessions?X-Plex-Token=" . $this->config['plexToken'];
-		$options = ($this->localURL($url)) ? array('verify' => false) : array();
-		$response = Requests::get($url, array(), $options);
-		libxml_use_internal_errors(true);
-		if ($response->success) {
-			$items = array();
-			$plex = simplexml_load_string($response->body);
-			foreach ($plex as $child) {
-				if (!in_array($child['type'], $ignore) && isset($child['librarySectionID'])) {
-					$items[] = $this->resolvePlexItem($child);
+		if (!$this->qualifyRequest(1)) {
+			return false;
+		}
+		$url = $this->qualifyURL($this->config['tautulliURL']);
+		$url .= '/api/v2?apikey=' . $this->config['tautulliApikey'];
+		$url .= '&cmd=get_users';
+		$response = Requests::get($url, [], []);
+		$names = [];
+		try {
+			$response = json_decode($response->body, true);
+			foreach ($response['response']['data'] as $user) {
+				if ($user['user_id'] != 0) {
+					$names[$user['username']] = $user['friendly_name'];
 				}
 			}
-			$api['content'] = ($resolve) ? $items : $plex;
-			$api['plexID'] = $this->config['plexID'];
-			$api['showNames'] = true;
-			$api['group'] = '1';
-			$this->setAPIResponse('success', null, 200, $api);
-			return $api;
+		} catch (Exception $e) {
+			$this->setAPIResponse('failure', null, 422, [$e->getMessage()]);
 		}
+		$this->setAPIResponse('success', null, 200, $names);
 	}
 	
-	public function getPlexHomepageRecent()
+	private function formatPlexUserName($item)
 	{
-		if (!$this->config['homepagePlexEnabled']) {
-			$this->setAPIResponse('error', 'Plex homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->config['homepagePlexRecent']) {
-			$this->setAPIResponse('error', 'Plex homepage module is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepagePlexAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepagePlexRecentAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage module', 401);
-			return false;
-		}
-		if (empty($this->config['plexURL'])) {
-			$this->setAPIResponse('error', 'Plex URL is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['plexToken'])) {
-			$this->setAPIResponse('error', 'Plex Token is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['plexID'])) {
-			$this->setAPIResponse('error', 'Plex Id is not defined', 422);
-			return false;
-		}
-		$ignore = array();
-		$resolve = true;
-		$url = $this->qualifyURL($this->config['plexURL']);
-		$urls['movie'] = $url . "/hubs/home/recentlyAdded?X-Plex-Token=" . $this->config['plexToken'] . "&X-Plex-Container-Start=0&X-Plex-Container-Size=" . $this->config['homepageRecentLimit'] . "&type=1";
-		$urls['tv'] = $url . "/hubs/home/recentlyAdded?X-Plex-Token=" . $this->config['plexToken'] . "&X-Plex-Container-Start=0&X-Plex-Container-Size=" . $this->config['homepageRecentLimit'] . "&type=2";
-		$urls['music'] = $url . "/hubs/home/recentlyAdded?X-Plex-Token=" . $this->config['plexToken'] . "&X-Plex-Container-Start=0&X-Plex-Container-Size=" . $this->config['homepageRecentLimit'] . "&type=8";
-		foreach ($urls as $k => $v) {
-			$options = ($this->localURL($v)) ? array('verify' => false) : array();
-			$response = Requests::get($v, array(), $options);
-			libxml_use_internal_errors(true);
-			if ($response->success) {
-				$items = array();
-				$plex = simplexml_load_string($response->body);
-				foreach ($plex as $child) {
-					if (!in_array($child['type'], $ignore) && isset($child['librarySectionID'])) {
-						$items[] = $this->resolvePlexItem($child);
-					}
-				}
-				if (isset($api)) {
-					$api['content'] = array_merge($api['content'], ($resolve) ? $items : $plex);
-				} else {
-					$api['content'] = ($resolve) ? $items : $plex;
+		$name = ($this->config['homepageShowStreamNames'] && $this->qualifyRequest($this->config['homepageShowStreamNamesAuth'])) ? (string)$item->User['title'] : "";
+		try {
+			if ($this->config['homepageUseCustomStreamNames']) {
+				$customNames = json_decode($this->config['homepageCustomStreamNames'], true);
+				if (array_key_exists($name, $customNames)) {
+					$name = $customNames[$name];
 				}
 			}
+		} catch (Exception $e) {
+			// don't do anythig if it goes wrong, like if the JSON is badly formatted
 		}
-		if (isset($api['content'])) {
-			usort($api['content'], function ($a, $b) {
-				return $b['addedAt'] <=> $a['addedAt'];
-			});
-		}
-		$api['plexID'] = $this->config['plexID'];
-		$api['showNames'] = true;
-		$api['group'] = '1';
-		$this->setAPIResponse('success', null, 200, $api);
-		return $api;
+		return $name;
 	}
 	
-	public function getPlexHomepageMetadata($array)
+	public function plexLibraryList($value = 'id')
 	{
-		if (!$this->config['homepagePlexEnabled']) {
-			$this->setAPIResponse('error', 'Plex homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->config['homepagePlexStreams']) {
-			$this->setAPIResponse('error', 'Plex homepage module is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepagePlexAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepagePlexStreamsAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage module', 401);
-			return false;
-		}
-		if (empty($this->config['plexURL'])) {
-			$this->setAPIResponse('error', 'Plex URL is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['plexToken'])) {
-			$this->setAPIResponse('error', 'Plex Token is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['plexID'])) {
-			$this->setAPIResponse('error', 'Plex Id is not defined', 422);
-			return false;
-		}
-		$key = $array['key'] ?? null;
-		if (!$key) {
-			$this->setAPIResponse('error', 'Plex Metadata key is not defined', 422);
-			return false;
-		}
-		$ignore = array();
-		$resolve = true;
-		$url = $this->qualifyURL($this->config['plexURL']);
-		$url = $url . "/library/metadata/" . $key . "?X-Plex-Token=" . $this->config['plexToken'];
-		$options = ($this->localURL($url)) ? array('verify' => false) : array();
-		$response = Requests::get($url, array(), $options);
-		libxml_use_internal_errors(true);
-		if ($response->success) {
-			$items = array();
-			$plex = simplexml_load_string($response->body);
-			foreach ($plex as $child) {
-				if (!in_array($child['type'], $ignore) && isset($child['librarySectionID'])) {
-					$items[] = $this->resolvePlexItem($child);
-				}
-			}
-			$api['content'] = ($resolve) ? $items : $plex;
-			$api['plexID'] = $this->config['plexID'];
-			$api['showNames'] = true;
-			$api['group'] = '1';
-			$this->setAPIResponse('success', null, 200, $api);
-			return $api;
-		}
-	}
-	
-	public function getPlexHomepagePlaylists()
-	{
-		if (!$this->config['homepagePlexEnabled']) {
-			$this->setAPIResponse('error', 'Plex homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->config['homepagePlexPlaylist']) {
-			$this->setAPIResponse('error', 'Plex homepage module is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepagePlexAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepagePlexPlaylistAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage module', 401);
-			return false;
-		}
-		if (empty($this->config['plexURL'])) {
-			$this->setAPIResponse('error', 'Plex URL is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['plexToken'])) {
-			$this->setAPIResponse('error', 'Plex Token is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['plexID'])) {
-			$this->setAPIResponse('error', 'Plex Id is not defined', 422);
-			return false;
-		}
-		$url = $this->qualifyURL($this->config['plexURL']);
-		$url = $url . "/playlists?X-Plex-Token=" . $this->config['plexToken'];
-		$options = ($this->localURL($url)) ? array('verify' => false) : array();
-		$response = Requests::get($url, array(), $options);
-		libxml_use_internal_errors(true);
-		if ($response->success) {
-			$items = array();
-			$plex = simplexml_load_string($response->body);
-			foreach ($plex as $child) {
-				if ($child['playlistType'] == "video" && strpos(strtolower($child['title']), 'private') === false) {
-					$playlistTitleClean = preg_replace("/(\W)+/", "", (string)$child['title']);
-					$playlistURL = $this->qualifyURL($this->config['plexURL']);
-					$playlistURL = $playlistURL . $child['key'] . "?X-Plex-Token=" . $this->config['plexToken'];
-					$options = ($this->localURL($url)) ? array('verify' => false) : array();
-					$playlistResponse = Requests::get($playlistURL, array(), $options);
-					if ($playlistResponse->success) {
-						$playlistResponse = simplexml_load_string($playlistResponse->body);
-						$items[$playlistTitleClean]['title'] = (string)$child['title'];
-						foreach ($playlistResponse->Video as $playlistItem) {
-							$items[$playlistTitleClean][] = $this->resolvePlexItem($playlistItem);
-						}
-					}
-				}
-			}
-			$api['content'] = $items;
-			$api['plexID'] = $this->config['plexID'];
-			$api['showNames'] = true;
-			$api['group'] = '1';
-			$this->setAPIResponse('success', null, 200, $api);
-			return $api;
-		} else {
-			$this->setAPIResponse('error', 'Plex API error', 500);
-			return false;
-		}
 		
-	}
-	
-	public function getPlexHomepageSearch($query)
-	{
-		if (!$this->config['homepagePlexEnabled']) {
-			$this->setAPIResponse('error', 'Plex homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepagePlexAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (empty($this->config['plexURL'])) {
-			$this->setAPIResponse('error', 'Plex URL is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['plexToken'])) {
-			$this->setAPIResponse('error', 'Plex Token is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['plexID'])) {
-			$this->setAPIResponse('error', 'Plex Id is not defined', 422);
-			return false;
-		}
-		$query = $query ?? null;
-		if (!$query) {
-			$this->setAPIResponse('error', 'Plex Metadata key is not defined', 422);
-			return false;
-		}
-		$ignore = array('artist', 'episode');
-		$resolve = true;
-		$url = $this->qualifyURL($this->config['plexURL']);
-		$url = $url . "/search?query=" . rawurlencode($query) . "&X-Plex-Token=" . $this->config['plexToken'];
-		$options = ($this->localURL($url)) ? array('verify' => false) : array();
-		$response = Requests::get($url, array(), $options);
-		libxml_use_internal_errors(true);
-		if ($response->success) {
-			$items = array();
-			$plex = simplexml_load_string($response->body);
-			foreach ($plex as $child) {
-				if (!in_array($child['type'], $ignore) && isset($child['librarySectionID'])) {
-					$items[] = $this->resolvePlexItem($child);
+		if (!empty($this->config['plexToken']) && !empty($this->config['plexID'])) {
+			$url = 'https://plex.tv/api/servers/' . $this->config['plexID'];
+			try {
+				$headers = array(
+					"Accept" => "application/json",
+					"X-Plex-Token" => $this->config['plexToken']
+				);
+				$response = Requests::get($url, $headers, array());
+				libxml_use_internal_errors(true);
+				if ($response->success) {
+					$libraryList = array();
+					$plex = simplexml_load_string($response->body);
+					foreach ($plex->Server->Section as $child) {
+						$libraryList['libraries'][(string)$child['title']] = (string)$child[$value];
+					}
+					$libraryList = array_change_key_case($libraryList, CASE_LOWER);
+					return $libraryList;
 				}
-			}
-			$api['content'] = ($resolve) ? $items : $plex;
-			$api['plexID'] = $this->config['plexID'];
-			$api['showNames'] = true;
-			$api['group'] = '1';
-			$this->setAPIResponse('success', null, 200, $api);
-			return $api;
+			} catch (Requests_Exception $e) {
+				$this->writeLog('error', 'Plex Connect Function - Error: ' . $e->getMessage(), 'SYSTEM');
+				return false;
+			};
 		}
+		return false;
 	}
-}
+}

+ 51 - 11
api/homepage/qbittorrent.php

@@ -34,6 +34,12 @@ trait QBitTorrentHomepageItem
 						'help' => 'Please make sure to use local IP address and port - You also may use local dns name too.',
 						'placeholder' => 'http(s)://hostname:port'
 					),
+					array(
+						'type' => 'switch',
+						'name' => 'qBittorrentDisableCertCheck',
+						'label' => 'Disable Certificate Check',
+						'value' => $this->config['qBittorrentDisableCertCheck']
+					),
 					array(
 						'type' => 'select',
 						'name' => 'qBittorrentApiVersion',
@@ -123,7 +129,7 @@ trait QBitTorrentHomepageItem
 		$apiVersionQuery = ($this->config['qBittorrentApiVersion'] == '1') ? '/query/torrents?sort=' : '/api/v2/torrents/info?sort=';
 		$url = $digest['scheme'] . '://' . $digest['host'] . $digest['port'] . $digest['path'] . $apiVersionLogin;
 		try {
-			$options = ($this->localURL($this->config['qBittorrentURL'])) ? array('verify' => false) : array();
+			$options = $this->requestOptions($this->config['qBittorrentURL'], $this->config['qBittorrentDisableCertCheck'], $this->config['homepageDownloadRefresh']);
 			$response = Requests::post($url, array(), $data, $options);
 			$reflection = new ReflectionClass($response->cookies);
 			$cookie = $reflection->getProperty("cookies");
@@ -161,18 +167,52 @@ trait QBitTorrentHomepageItem
 		}
 	}
 	
-	public function getQBittorrentHomepageQueue()
+	public function qBittorrentHomepagePermissions($key = null)
 	{
-		if (!$this->config['homepageqBittorrentEnabled']) {
-			$this->setAPIResponse('error', 'qBittorrent homepage item is not enabled', 409);
-			return false;
+		$permissions = [
+			'main' => [
+				'enabled' => [
+					'homepageqBittorrentEnabled'
+				],
+				'auth' => [
+					'homepageqBittorrentAuth'
+				],
+				'not_empty' => [
+					'qBittorrentURL'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
 		}
-		if (!$this->qualifyRequest($this->config['homepageqBittorrentAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
+	}
+	
+	public function homepageOrderqBittorrent()
+	{
+		if ($this->homepageItemPermissions($this->qBittorrentHomepagePermissions('main'))) {
+			$loadingBox = ($this->config['qBittorrentCombine']) ? '' : '<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Download Queue...</h2></div>';
+			$builder = ($this->config['qBittorrentCombine']) ? 'buildDownloaderCombined(\'qBittorrent\');' : '$("#' . __FUNCTION__ . '").html(buildDownloader("qBittorrent"));';
+			return '
+				<div id="' . __FUNCTION__ . '">
+					' . $loadingBox . '
+					<script>
+		                // homepageOrderqBittorrent
+		                ' . $builder . '
+		                homepageDownloader("qBittorrent", "' . $this->config['homepageDownloadRefresh'] . '");
+		                // End homepageOrderqBittorrent
+	                </script>
+				</div>
+				';
 		}
-		if (empty($this->config['qBittorrentURL'])) {
-			$this->setAPIResponse('error', 'qBittorrent URL is not defined', 422);
+	}
+	
+	public function getQBittorrentHomepageQueue()
+	{
+		if (!$this->homepageItemPermissions($this->qBittorrentHomepagePermissions('main'), true)) {
 			return false;
 		}
 		$digest = $this->qualifyURL($this->config['qBittorrentURL'], true);
@@ -181,7 +221,7 @@ trait QBitTorrentHomepageItem
 		$apiVersionQuery = ($this->config['qBittorrentApiVersion'] == '1') ? '/query/torrents?sort=' : '/api/v2/torrents/info?sort=';
 		$url = $digest['scheme'] . '://' . $digest['host'] . $digest['port'] . $digest['path'] . $apiVersionLogin;
 		try {
-			$options = ($this->localURL($this->config['qBittorrentURL'])) ? array('verify' => false) : array();
+			$options = $this->requestOptions($this->config['qBittorrentURL'], $this->config['qBittorrentDisableCertCheck'], $this->config['homepageDownloadRefresh']);
 			$response = Requests::post($url, array(), $data, $options);
 			$reflection = new ReflectionClass($response->cookies);
 			$cookie = $reflection->getProperty("cookies");

+ 128 - 49
api/homepage/radarr.php

@@ -155,6 +155,30 @@ trait RadarrHomepageItem
 						'label' => 'Refresh Seconds',
 						'value' => $this->config['calendarRefresh'],
 						'options' => $this->timeOptions()
+					),
+					array(
+						'type' => 'switch',
+						'name' => 'radarrUnmonitored',
+						'label' => 'Show Unmonitored',
+						'value' => $this->config['radarrUnmonitored']
+					),
+					array(
+						'type' => 'switch',
+						'name' => 'radarrPhysicalRelease',
+						'label' => 'Show Physical Release',
+						'value' => $this->config['radarrPhysicalRelease']
+					),
+					array(
+						'type' => 'switch',
+						'name' => 'radarrDigitalRelease',
+						'label' => 'Show Digital Release',
+						'value' => $this->config['radarrDigitalRelease']
+					),
+					array(
+						'type' => 'switch',
+						'name' => 'radarrCinemaRelease',
+						'label' => 'Show Cinema Releases',
+						'value' => $this->config['radarrCinemaRelease']
 					)
 				),
 				'Test Connection' => array(
@@ -190,8 +214,8 @@ trait RadarrHomepageItem
 		$list = $this->csvHomepageUrlToken($this->config['radarrURL'], $this->config['radarrToken']);
 		foreach ($list as $key => $value) {
 			try {
-				$downloader = new Kryptonit3\Sonarr\Sonarr($value['url'], $value['token']);
-				$results = $downloader->getSystemStatus();
+				$downloader = new Kryptonit3\Sonarr\Sonarr($value['url'], $value['token'], 'radarr');
+				$results = $downloader->getRootFolder();
 				$downloadList = json_decode($results, true);
 				if (is_array($downloadList) || is_object($downloadList)) {
 					$queue = (array_key_exists('error', $downloadList)) ? $downloadList['error']['msg'] : $downloadList;
@@ -222,37 +246,74 @@ trait RadarrHomepageItem
 		}
 	}
 	
-	public function getRadarrQueue()
+	public function radarrHomepagePermissions($key = null)
 	{
-		if (!$this->config['homepageRadarrEnabled']) {
-			$this->setAPIResponse('error', 'Radarr homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->config['homepageRadarrQueueEnabled']) {
-			$this->setAPIResponse('error', 'Radarr homepage module is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageRadarrAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageRadarrQueueAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage module', 401);
-			return false;
+		$permissions = [
+			'calendar' => [
+				'enabled' => [
+					'homepageRadarrEnabled'
+				],
+				'auth' => [
+					'homepageRadarrAuth'
+				],
+				'not_empty' => [
+					'radarrURL',
+					'radarrToken'
+				]
+			],
+			'queue' => [
+				'enabled' => [
+					'homepageRadarrEnabled',
+					'homepageRadarrQueueEnabled'
+				],
+				'auth' => [
+					'homepageRadarrAuth',
+					'homepageRadarrQueueAuth'
+				],
+				'not_empty' => [
+					'radarrURL',
+					'radarrToken'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
 		}
-		if (empty($this->config['radarrURL'])) {
-			$this->setAPIResponse('error', 'Radarr URL is not defined', 422);
-			return false;
+	}
+	
+	public function homepageOrderRadarrQueue()
+	{
+		if ($this->homepageItemPermissions($this->radarrHomepagePermissions('queue'))) {
+			$loadingBox = ($this->config['homepageRadarrQueueCombine']) ? '' : '<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Download Queue...</h2></div>';
+			$builder = ($this->config['homepageRadarrQueueCombine']) ? 'buildDownloaderCombined(\'radarr\');' : '$("#' . __FUNCTION__ . '").html(buildDownloader("radarr"));';
+			return '
+				<div id="' . __FUNCTION__ . '">
+					' . $loadingBox . '
+					<script>
+		                // homepageOrderRadarrQueue
+		                ' . $builder . '
+		                homepageDownloader("radarr", "' . $this->config['homepageRadarrQueueRefresh'] . '");
+		                // End homepageOrderRadarrQueue
+	                </script>
+				</div>
+				';
 		}
-		if (empty($this->config['radarrToken'])) {
-			$this->setAPIResponse('error', 'Radarr Token is not defined', 422);
+	}
+	
+	public function getRadarrQueue()
+	{
+		if (!$this->homepageItemPermissions($this->radarrHomepagePermissions('queue'), true)) {
 			return false;
 		}
 		$queueItems = array();
 		$list = $this->csvHomepageUrlToken($this->config['radarrURL'], $this->config['radarrToken']);
 		foreach ($list as $key => $value) {
 			try {
-				$downloader = new Kryptonit3\Sonarr\Sonarr($value['url'], $value['token']);
+				$downloader = new Kryptonit3\Sonarr\Sonarr($value['url'], $value['token'], 'radarr');
 				$results = $downloader->getQueue();
 				$downloadList = json_decode($results, true);
 				if (is_array($downloadList) || is_object($downloadList)) {
@@ -278,28 +339,15 @@ trait RadarrHomepageItem
 	{
 		$startDate = ($startDate) ?? $_GET['start'];
 		$endDate = ($endDate) ?? $_GET['end'];
-		if (!$this->config['homepageRadarrEnabled']) {
-			$this->setAPIResponse('error', 'Radarr homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageRadarrAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (empty($this->config['radarrURL'])) {
-			$this->setAPIResponse('error', 'Radarr URL is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['radarrToken'])) {
-			$this->setAPIResponse('error', 'Radarr Token is not defined', 422);
+		if (!$this->homepageItemPermissions($this->radarrHomepagePermissions('calendar'), true)) {
 			return false;
 		}
 		$calendarItems = array();
 		$list = $this->csvHomepageUrlToken($this->config['radarrURL'], $this->config['radarrToken']);
 		foreach ($list as $key => $value) {
 			try {
-				$downloader = new Kryptonit3\Sonarr\Sonarr($value['url'], $value['token']);
-				$results = $downloader->getCalendar($startDate, $endDate);
+				$downloader = new Kryptonit3\Sonarr\Sonarr($value['url'], $value['token'], 'radarr');
+				$results = $downloader->getCalendar($startDate, $endDate, $this->config['radarrUnmonitored']);
 				$result = json_decode($results, true);
 				if (is_array($result) || is_object($result)) {
 					$calendar = (array_key_exists('error', $result)) ? '' : $this->formatRadarrCalendar($results, $key, $value['url']);
@@ -325,17 +373,41 @@ trait RadarrHomepageItem
 		$gotCalendar = array();
 		$i = 0;
 		foreach ($array as $child) {
-			if (isset($child['physicalRelease'])) {
+			for ($j = 0; $j < 3; $j++) {
+				$type = [];
+				if ($j == 0 && $this->config['radarrPhysicalRelease'] && isset($child['physicalRelease'])) {
+					$releaseDate = $child['physicalRelease'];
+					array_push($type, "physical");
+					if (isset($child['digitalRelease']) && $child['physicalRelease'] == $child['digitalRelease']) {
+						array_push($type, "digital");
+						$j++;
+					}
+					if (isset($child['inCinemas']) && $child['physicalRelease'] == $child['inCinemas']) {
+						array_push($type, "cinema");
+						$j += 2;
+					}
+				} elseif ($j == 1 && $this->config['radarrDigitalRelease'] && isset($child['digitalRelease'])) {
+					$releaseDate = $child['digitalRelease'];
+					array_push($type, "digital");
+					if (isset($child['inCinemas']) && $child['digitalRelease'] == $child['inCinemas']) {
+						array_push($type, "cinema");
+						$j++;
+					}
+				} elseif ($j == 2 && $this->config['radarrCinemaRelease'] && isset($child['inCinemas'])) {
+					$releaseDate = $child['inCinemas'];
+					array_push($type, "cinema");
+				} else {
+					continue;
+				}
 				$i++;
 				$movieName = $child['title'];
 				$movieID = $child['tmdbId'];
 				if (!isset($movieID)) {
 					$movieID = "";
 				}
-				$physicalRelease = $child['physicalRelease'];
-				$physicalRelease = strtotime($physicalRelease);
-				$physicalRelease = date("Y-m-d", $physicalRelease);
-				if (new DateTime() < new DateTime($physicalRelease)) {
+				$releaseDate = strtotime($releaseDate);
+				$releaseDate = date("Y-m-d", $releaseDate);
+				if (new DateTime() < new DateTime($releaseDate)) {
 					$notReleased = "true";
 				} else {
 					$notReleased = "false";
@@ -378,10 +450,16 @@ trait RadarrHomepageItem
 					}
 				}
 				$alternativeTitles = "";
-				foreach ($child['alternativeTitles'] as $alternative) {
-					$alternativeTitles .= $alternative['title'] . ', ';
+				if (!empty($child['alternativeTitles'])) {
+					foreach ($child['alternativeTitles'] as $alternative) {
+						$alternativeTitles .= $alternative['title'] . ', ';
+					}
+				} elseif (!empty($child['alternateTitles'])) { //v3 API
+					foreach ($child['alternateTitles'] as $alternative) {
+						$alternativeTitles .= $alternative['title'] . ', ';
+					}
 				}
-				$alternativeTitles = empty($child['alternativeTitles']) ? "" : substr($alternativeTitles, 0, -2);
+				$alternativeTitles = empty($alternativeTitles) ? "" : substr($alternativeTitles, 0, -2);
 				$details = array(
 					"topTitle" => $movieName,
 					"bottomTitle" => $alternativeTitles,
@@ -402,11 +480,12 @@ trait RadarrHomepageItem
 				array_push($gotCalendar, array(
 					"id" => "Radarr-" . $number . "-" . $i,
 					"title" => $movieName,
-					"start" => $physicalRelease,
+					"start" => $releaseDate,
 					"className" => "inline-popups bg-calendar movieID--" . $movieID,
 					"imagetype" => "film " . $downloaded,
 					"imagetypeFilter" => "film",
 					"downloadFilter" => $downloaded,
+					"releaseType" => $type,
 					"bgColor" => str_replace('text', 'bg', $downloaded),
 					"details" => $details
 				));

+ 78 - 42
api/homepage/rtorrent.php

@@ -160,10 +160,9 @@ trait RTorrentHomepageItem
 		}
 		try {
 			$digest = (empty($this->config['rTorrentURLOverride'])) ? $this->qualifyURL($this->config['rTorrentURL'], true) : $this->qualifyURL($this->checkOverrideURL($this->config['rTorrentURL'], $this->config['rTorrentURLOverride']), true);
-			$passwordInclude = ($this->config['rTorrentUsername'] !== '' && $this->config['rTorrentPassword'] !== '') ? $this->config['rTorrentUsername'] . ':' . $this->decrypt($this->config['rTorrentPassword']) . "@" : '';
 			$extraPath = (strpos($this->config['rTorrentURL'], '.php') !== false) ? '' : '/RPC2';
 			$extraPath = (empty($this->config['rTorrentURLOverride'])) ? $extraPath : '';
-			$url = $digest['scheme'] . '://' . $passwordInclude . $digest['host'] . $digest['port'] . $digest['path'] . $extraPath;
+			$url = $digest['scheme'] . '://' . $digest['host'] . $digest['port'] . $digest['path'] . $extraPath;
 			$options = ($this->localURL($url, $this->config['rTorrentDisableCertCheck'])) ? array('verify' => false) : array();
 			if ($this->config['rTorrentUsername'] !== '' && $this->decrypt($this->config['rTorrentPassword']) !== '') {
 				$credentials = array('auth' => new Requests_Auth_Digest(array($this->config['rTorrentUsername'], $this->decrypt($this->config['rTorrentPassword']))));
@@ -188,6 +187,47 @@ trait RTorrentHomepageItem
 		}
 	}
 	
+	public function rTorrentHomepagePermissions($key = null)
+	{
+		$permissions = [
+			'main' => [
+				'enabled' => [
+					'homepagerTorrentEnabled'
+				],
+				'auth' => [
+					'homepagerTorrentAuth'
+				],
+				'not_empty' => []
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
+		}
+	}
+	
+	public function homepageOrderrTorrent()
+	{
+		if ($this->homepageItemPermissions($this->rTorrentHomepagePermissions('main'))) {
+			$loadingBox = ($this->config['rTorrentCombine']) ? '' : '<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Download Queue...</h2></div>';
+			$builder = ($this->config['rTorrentCombine']) ? 'buildDownloaderCombined(\'rTorrent\');' : '$("#' . __FUNCTION__ . '").html(buildDownloader("rTorrent"));';
+			return '
+				<div id="' . __FUNCTION__ . '">
+					' . $loadingBox . '
+					<script>
+		                // homepageOrderrTorrent
+		                ' . $builder . '
+		                homepageDownloader("rTorrent", "' . $this->config['homepageDownloadRefresh'] . '");
+		                // End homepageOrderrTorrent
+	                </script>
+				</div>
+				';
+		}
+	}
+	
 	public function checkOverrideURL($url, $override)
 	{
 		if (strpos($override, $url) !== false) {
@@ -213,29 +253,23 @@ trait RTorrentHomepageItem
 	
 	public function getRTorrentHomepageQueue()
 	{
-		if (!$this->config['homepagerTorrentEnabled']) {
-			$this->setAPIResponse('error', 'rTorrent homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepagerTorrentAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
 		if (empty($this->config['rTorrentURL']) && empty($this->config['rTorrentURLOverride'])) {
 			$this->setAPIResponse('error', 'rTorrent URL is not defined', 422);
 			return false;
 		}
+		if (!$this->homepageItemPermissions($this->rTorrentHomepagePermissions('main'), true)) {
+			return false;
+		}
 		try {
 			if ($this->config['rTorrentLimit'] == '0') {
 				$this->config['rTorrentLimit'] = '1000';
 			}
 			$torrents = array();
 			$digest = (empty($this->config['rTorrentURLOverride'])) ? $this->qualifyURL($this->config['rTorrentURL'], true) : $this->qualifyURL($this->checkOverrideURL($this->config['rTorrentURL'], $this->config['rTorrentURLOverride']), true);
-			$passwordInclude = ($this->config['rTorrentUsername'] !== '' && $this->config['rTorrentPassword'] !== '') ? $this->config['rTorrentUsername'] . ':' . $this->decrypt($this->config['rTorrentPassword']) . "@" : '';
 			$extraPath = (strpos($this->config['rTorrentURL'], '.php') !== false) ? '' : '/RPC2';
 			$extraPath = (empty($this->config['rTorrentURLOverride'])) ? $extraPath : '';
-			$url = $digest['scheme'] . '://' . $passwordInclude . $digest['host'] . $digest['port'] . $digest['path'] . $extraPath;
-			$options = (localURL($url, $this->config['rTorrentDisableCertCheck'])) ? array('verify' => false) : array();
+			$url = $digest['scheme'] . '://' . $digest['host'] . $digest['port'] . $digest['path'] . $extraPath;
+			$options = ($this->localURL($url, $this->config['rTorrentDisableCertCheck'])) ? array('verify' => false) : array();
 			if ($this->config['rTorrentUsername'] !== '' && $this->decrypt($this->config['rTorrentPassword']) !== '') {
 				$credentials = array('auth' => new Requests_Auth_Digest(array($this->config['rTorrentUsername'], $this->decrypt($this->config['rTorrentPassword']))));
 				$options = array_merge($options, $credentials);
@@ -272,35 +306,37 @@ trait RTorrentHomepageItem
 			$response = Requests::post($url, array(), $data, $options);
 			if ($response->success) {
 				$torrentList = xmlrpc_decode(str_replace('i8>', 'string>', $response->body));
-				foreach ($torrentList as $key => $value) {
-					$tempStatus = $this->rTorrentStatus($value[13], $value[10], $value[6]);
-					if ($tempStatus == 'Seeding' && $this->config['rTorrentHideSeeding']) {
-						//do nothing
-					} elseif ($tempStatus == 'Finished' && $this->config['rTorrentHideCompleted']) {
-						//do nothing
-					} else {
-						$torrents[$key] = array(
-							'name' => $value[0],
-							'base' => $value[1],
-							'upTotal' => $value[2],
-							'size' => $value[3],
-							'downTotal' => $value[4],
-							'downloaded' => $value[5],
-							'connectionState' => $value[6],
-							'leech' => $value[7],
-							'seed' => $value[8],
-							'date' => $value[9],
-							'state' => ($value[10]) ? 'on' : 'off',
-							'group' => $value[11],
-							'hash' => $value[12],
-							'complete' => ($value[13]) ? 'yes' : 'no',
-							'ratio' => $value[14],
-							'label' => $value[20],
-							'status' => $tempStatus,
-							'temp' => $value[16] . ' - ' . $value[17] . ' - ' . $value[18],
-							'custom' => $value[19] . ' - ' . $value[20] . ' - ' . $value[21],
-							'custom2' => $value[22] . ' - ' . $value[23] . ' - ' . $value[24],
-						);
+				if (is_array($torrentList)) {
+					foreach ($torrentList as $key => $value) {
+						$tempStatus = $this->rTorrentStatus($value[13], $value[10], $value[6]);
+						if ($tempStatus == 'Seeding' && $this->config['rTorrentHideSeeding']) {
+							//do nothing
+						} elseif ($tempStatus == 'Finished' && $this->config['rTorrentHideCompleted']) {
+							//do nothing
+						} else {
+							$torrents[$key] = array(
+								'name' => $value[0],
+								'base' => $value[1],
+								'upTotal' => $value[2],
+								'size' => $value[3],
+								'downTotal' => $value[4],
+								'downloaded' => $value[5],
+								'connectionState' => $value[6],
+								'leech' => $value[7],
+								'seed' => $value[8],
+								'date' => $value[9],
+								'state' => ($value[10]) ? 'on' : 'off',
+								'group' => $value[11],
+								'hash' => $value[12],
+								'complete' => ($value[13]) ? 'yes' : 'no',
+								'ratio' => $value[14],
+								'label' => $value[20],
+								'status' => $tempStatus,
+								'temp' => $value[16] . ' - ' . $value[17] . ' - ' . $value[18],
+								'custom' => $value[19] . ' - ' . $value[20] . ' - ' . $value[21],
+								'custom2' => $value[22] . ' - ' . $value[23] . ' - ' . $value[24],
+							);
+						}
 					}
 				}
 				if (count($torrents) !== 0) {

+ 46 - 41
api/homepage/sabnzbd.php

@@ -97,22 +97,53 @@ trait SabNZBdHomepageItem
 		}
 	}
 	
-	public function getSabNZBdHomepageQueue()
+	public function sabNZBdHomepagePermissions($key = null)
 	{
-		if (!$this->config['homepageSabnzbdEnabled']) {
-			$this->setAPIResponse('error', 'SabNZBd homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageSabnzbdAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
+		$permissions = [
+			'main' => [
+				'enabled' => [
+					'homepageSabnzbdEnabled'
+				],
+				'auth' => [
+					'homepageSabnzbdAuth'
+				],
+				'not_empty' => [
+					'sabnzbdURL',
+					'sabnzbdToken'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
 		}
-		if (empty($this->config['sabnzbdURL'])) {
-			$this->setAPIResponse('error', 'Plex URL is not defined', 422);
-			return false;
+	}
+	
+	public function homepageOrdersabnzbd()
+	{
+		if ($this->homepageItemPermissions($this->sabNZBdHomepagePermissions('main'))) {
+			$loadingBox = ($this->config['sabnzbdCombine']) ? '' : '<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Download Queue...</h2></div>';
+			$builder = ($this->config['sabnzbdCombine']) ? 'buildDownloaderCombined(\'sabnzbd\');' : '$("#' . __FUNCTION__ . '").html(buildDownloader("sabnzbd"));';
+			return '
+				<div id="' . __FUNCTION__ . '">
+					' . $loadingBox . '
+					<script>
+		                // homepageOrdersabnzbd
+		                ' . $builder . '
+		                homepageDownloader("sabnzbd", "' . $this->config['homepageDownloadRefresh'] . '");
+		                // End homepageOrdersabnzbd
+	                </script>
+				</div>
+				';
 		}
-		if (empty($this->config['sabnzbdToken'])) {
-			$this->setAPIResponse('error', 'Plex Token is not defined', 422);
+	}
+	
+	public function getSabNZBdHomepageQueue()
+	{
+		if (!$this->homepageItemPermissions($this->sabNZBdHomepagePermissions('main'), true)) {
 			return false;
 		}
 		$url = $this->qualifyURL($this->config['sabnzbdURL']);
@@ -148,20 +179,7 @@ trait SabNZBdHomepageItem
 	
 	public function pauseSabNZBdQueue($target = null)
 	{
-		if (!$this->config['homepageSabnzbdEnabled']) {
-			$this->setAPIResponse('error', 'SabNZBd homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageSabnzbdAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (empty($this->config['sabnzbdURL'])) {
-			$this->setAPIResponse('error', 'Plex URL is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['sabnzbdToken'])) {
-			$this->setAPIResponse('error', 'Plex Token is not defined', 422);
+		if (!$this->homepageItemPermissions($this->sabNZBdHomepagePermissions('main'), true)) {
 			return false;
 		}
 		$url = $this->qualifyURL($this->config['sabnzbdURL']);
@@ -185,20 +203,7 @@ trait SabNZBdHomepageItem
 	
 	public function resumeSabNZBdQueue($target = null)
 	{
-		if (!$this->config['homepageSabnzbdEnabled']) {
-			$this->setAPIResponse('error', 'SabNZBd homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageSabnzbdAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (empty($this->config['sabnzbdURL'])) {
-			$this->setAPIResponse('error', 'Plex URL is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['sabnzbdToken'])) {
-			$this->setAPIResponse('error', 'Plex Token is not defined', 422);
+		if (!$this->homepageItemPermissions($this->sabNZBdHomepagePermissions('main'), true)) {
 			return false;
 		}
 		$url = $this->qualifyURL($this->config['sabnzbdURL']);

+ 25 - 18
api/homepage/sickrage.php

@@ -150,27 +150,34 @@ trait SickRageHomepageItem
 		}
 	}
 	
+	public function sickrageHomepagePermissions($key = null)
+	{
+		$permissions = [
+			'calendar' => [
+				'enabled' => [
+					'homepageSickrageEnabled'
+				],
+				'auth' => [
+					'homepageSickrageAuth'
+				],
+				'not_empty' => [
+					'sickrageURL',
+					'sickrageToken'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
+		}
+	}
 	
 	public function getSickRageCalendar($startDate = null, $endDate = null)
 	{
-		if (!$this->config['homepageSickrageEnabled']) {
-			$this->setAPIResponse('error', 'SickRage homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageSickrageAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageRadarrQueueAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage module', 401);
-			return false;
-		}
-		if (empty($this->config['sickrageURL'])) {
-			$this->setAPIResponse('error', 'SickRage URL is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['sickrageToken'])) {
-			$this->setAPIResponse('error', 'SickRage Token is not defined', 422);
+		if (!$this->homepageItemPermissions($this->sickrageHomepagePermissions('calendar'), true)) {
 			return false;
 		}
 		$calendarItems = array();

+ 80 - 39
api/homepage/sonarr.php

@@ -9,7 +9,24 @@ trait SonarrHomepageItem
 			'enabled' => strpos('personal', $this->config['license']) !== false,
 			'image' => 'plugins/images/tabs/sonarr.png',
 			'category' => 'PVR',
+			'docs' => 'https://docs.organizr.app/books/setup-features/page/sonarr',
 			'settings' => array(
+				'About' => array(
+					array(
+						'type' => 'html',
+						'override' => 12,
+						'label' => '',
+						'html' => '
+							<div class="panel panel-default">
+								<div class="panel-wrapper collapse in">
+									<div class="panel-body">
+										<h3 lang="en">Sonarr Homepage Item</h3>
+										<p lang="en">This item allows access to Sonarr\'s calendar data and aggregates it to Organizr\'s calendar.  Along with that you also have the Downloader function that allow access to Sonarr\'s queue.  The last item that is included is the API SOCKS function which acts as a middleman between API\'s which is useful if you are not port forwarding or reverse proxying Sonarr.</p>
+									</div>
+								</div>
+							</div>'
+					),
+				),
 				'Enable' => array(
 					array(
 						'type' => 'switch',
@@ -196,8 +213,8 @@ trait SonarrHomepageItem
 		$list = $this->csvHomepageUrlToken($this->config['sonarrURL'], $this->config['sonarrToken']);
 		foreach ($list as $key => $value) {
 			try {
-				$downloader = new Kryptonit3\Sonarr\Sonarr($value['url'], $value['token']);
-				$results = $downloader->getSystemStatus();
+				$downloader = new Kryptonit3\Sonarr\Sonarr($value['url'], $value['token'], 'sonarr');
+				$results = $downloader->getRootFolder();
 				$downloadList = json_decode($results, true);
 				if (is_array($downloadList) || is_object($downloadList)) {
 					$queue = (array_key_exists('error', $downloadList)) ? $downloadList['error']['msg'] : $downloadList;
@@ -228,37 +245,74 @@ trait SonarrHomepageItem
 		}
 	}
 	
-	public function getSonarrQueue()
+	public function sonarrHomepagePermissions($key = null)
 	{
-		if (!$this->config['homepageSonarrEnabled']) {
-			$this->setAPIResponse('error', 'Sonarr homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->config['homepageSonarrQueueEnabled']) {
-			$this->setAPIResponse('error', 'Sonarr homepage module is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageSonarrAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageSonarrQueueAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage module', 401);
-			return false;
+		$permissions = [
+			'calendar' => [
+				'enabled' => [
+					'homepageSonarrEnabled'
+				],
+				'auth' => [
+					'homepageSonarrAuth'
+				],
+				'not_empty' => [
+					'sonarrURL',
+					'sonarrToken'
+				]
+			],
+			'queue' => [
+				'enabled' => [
+					'homepageSonarrEnabled',
+					'homepageSonarrQueueEnabled'
+				],
+				'auth' => [
+					'homepageSonarrAuth',
+					'homepageSonarrQueueAuth'
+				],
+				'not_empty' => [
+					'sonarrURL',
+					'sonarrToken'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
 		}
-		if (empty($this->config['sonarrURL'])) {
-			$this->setAPIResponse('error', 'Sonarr URL is not defined', 422);
-			return false;
+	}
+	
+	public function homepageOrderSonarrQueue()
+	{
+		if ($this->homepageItemPermissions($this->sonarrHomepagePermissions('queue'))) {
+			$loadingBox = ($this->config['homepageSonarrQueueCombine']) ? '' : '<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Download Queue...</h2></div>';
+			$builder = ($this->config['homepageSonarrQueueCombine']) ? 'buildDownloaderCombined(\'sonarr\');' : '$("#' . __FUNCTION__ . '").html(buildDownloader("sonarr"));';
+			return '
+				<div id="' . __FUNCTION__ . '">
+					' . $loadingBox . '
+					<script>
+		                // homepageOrderSonarrQueue
+		                ' . $builder . '
+		                homepageDownloader("sonarr", "' . $this->config['homepageSonarrQueueRefresh'] . '");
+		                // End homepageOrderSonarrQueue
+	                </script>
+				</div>
+				';
 		}
-		if (empty($this->config['sonarrToken'])) {
-			$this->setAPIResponse('error', 'Sonarr Token is not defined', 422);
+	}
+	
+	public function getSonarrQueue()
+	{
+		if (!$this->homepageItemPermissions($this->sonarrHomepagePermissions('queue'), true)) {
 			return false;
 		}
 		$queueItems = array();
 		$list = $this->csvHomepageUrlToken($this->config['sonarrURL'], $this->config['sonarrToken']);
 		foreach ($list as $key => $value) {
 			try {
-				$downloader = new Kryptonit3\Sonarr\Sonarr($value['url'], $value['token']);
+				$downloader = new Kryptonit3\Sonarr\Sonarr($value['url'], $value['token'], 'sonarr');
 				$results = $downloader->getQueue();
 				$downloadList = json_decode($results, true);
 				if (is_array($downloadList) || is_object($downloadList)) {
@@ -284,27 +338,14 @@ trait SonarrHomepageItem
 	{
 		$startDate = ($startDate) ?? $_GET['start'];
 		$endDate = ($endDate) ?? $_GET['end'];
-		if (!$this->config['homepageSonarrEnabled']) {
-			$this->setAPIResponse('error', 'Sonarr homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageSonarrAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (empty($this->config['sonarrURL'])) {
-			$this->setAPIResponse('error', 'Sonarr URL is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['sonarrToken'])) {
-			$this->setAPIResponse('error', 'Sonarr Token is not defined', 422);
+		if (!$this->homepageItemPermissions($this->sonarrHomepagePermissions('calendar'), true)) {
 			return false;
 		}
 		$calendarItems = array();
 		$list = $this->csvHomepageUrlToken($this->config['sonarrURL'], $this->config['sonarrToken']);
 		foreach ($list as $key => $value) {
 			try {
-				$sonarr = new Kryptonit3\Sonarr\Sonarr($value['url'], $value['token']);
+				$sonarr = new Kryptonit3\Sonarr\Sonarr($value['url'], $value['token'], 'sonarr');
 				$sonarr = $sonarr->getCalendar($startDate, $endDate, $this->config['sonarrUnmonitored']);
 				$result = json_decode($sonarr, true);
 				if (is_array($result) || is_object($result)) {

+ 40 - 13
api/homepage/speedtest.php

@@ -60,18 +60,49 @@ trait SpeedTestHomepageItem
 		);
 	}
 	
-	public function getSpeedtestHomepageData()
+	public function speedTestHomepagePermissions($key = null)
 	{
-		if (!$this->config['homepageSpeedtestEnabled']) {
-			$this->setAPIResponse('error', 'SpeedTest homepage item is not enabled', 409);
-			return false;
+		$permissions = [
+			'main' => [
+				'enabled' => [
+					'homepageSpeedtestEnabled'
+				],
+				'auth' => [
+					'homepageSpeedtestAuth'
+				],
+				'not_empty' => [
+					'speedtestURL'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
 		}
-		if (!$this->qualifyRequest($this->config['homepageSpeedtestAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
+	}
+	
+	public function homepageOrderSpeedtest()
+	{
+		if ($this->homepageItemPermissions($this->speedTestHomepagePermissions('main'))) {
+			return '
+				<div id="' . __FUNCTION__ . '">
+					<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Speedtest...</h2></div>
+					<script>
+						// Speedtest
+						homepageSpeedtest("' . $this->config['homepageSpeedtestRefresh'] . '");
+						// End Speedtest
+					</script>
+				</div>
+				';
 		}
-		if (empty($this->config['speedtestURL'])) {
-			$this->setAPIResponse('error', 'SpeedTest URL is not defined', 422);
+	}
+	
+	public function getSpeedtestHomepageData()
+	{
+		if (!$this->homepageItemPermissions($this->speedTestHomepagePermissions('main'), true)) {
 			return false;
 		}
 		$api = [];
@@ -81,18 +112,15 @@ trait SpeedTestHomepageItem
 			$response = Requests::get($dataUrl);
 			if ($response->success) {
 				$json = json_decode($response->body, true);
-
 				$api['data'] = [
 					'current' => $json['data'],
 				];
-
 				$keys = [
 					'average',
 					'max',
 					'maximum',
 					'minimum'
 				];
-
 				foreach ($keys as $key) {
 					if (array_key_exists($key, $json)) {
 						if ($key == 'max') {
@@ -102,7 +130,6 @@ trait SpeedTestHomepageItem
 						}
 					}
 				}
-
 				$api['options'] = [
 					'title' => $this->config['speedtestHeader'],
 					'titleToggle' => $this->config['speedtestHeaderToggle'],

+ 55 - 16
api/homepage/tatutulli.php → api/homepage/tautulli.php

@@ -139,6 +139,13 @@ trait TautulliHomepageItem
 						'value' => $this->config['homepageTautulliMiscAuth'],
 						'options' => $this->groupOptions
 					),
+					array(
+						'type' => 'switch',
+						'name' => 'tautulliFriendlyName',
+						'label' => 'Use Friendly Name',
+						'value' => $this->config['tautulliFriendlyName'],
+						'help' => 'Use the friendly name set in tautulli for users.',
+					),
 				),
 				'Test Connection' => array(
 					array(
@@ -172,7 +179,8 @@ trait TautulliHomepageItem
 		$apiURL = $url . '/api/v2?apikey=' . $this->config['tautulliApikey'];
 		try {
 			$homestatsUrl = $apiURL . '&cmd=get_home_stats&grouping=1';
-			$homestats = Requests::get($homestatsUrl, [], []);
+			$options = $this->requestOptions($this->config['tautulliURL'], false, $this->config['homepageTautulliRefresh']);
+			$homestats = Requests::get($homestatsUrl, [], $options);
 			if ($homestats->success) {
 				$this->setAPIResponse('success', 'API Connection succeeded', 200);
 				return true;
@@ -187,22 +195,50 @@ trait TautulliHomepageItem
 		}
 	}
 	
-	public function getTautulliHomepageData()
+	public function tautulliHomepagePermissions($key = null)
 	{
-		if (!$this->config['homepageTautulliEnabled']) {
-			$this->setAPIResponse('error', 'Tautulli homepage item is not enabled', 409);
-			return false;
+		$permissions = [
+			'main' => [
+				'enabled' => [
+					'homepageTautulliEnabled'
+				],
+				'auth' => [
+					'homepageTautulliAuth'
+				],
+				'not_empty' => [
+					'tautulliURL',
+					'tautulliApikey'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
 		}
-		if (!$this->qualifyRequest($this->config['homepageTautulliAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (empty($this->config['tautulliURL'])) {
-			$this->setAPIResponse('error', 'Tautulli URL is not defined', 422);
-			return false;
+	}
+	
+	public function homepageOrdertautulli()
+	{
+		if ($this->homepageItemPermissions($this->tautulliHomepagePermissions('main'))) {
+			return '
+				<div id="' . __FUNCTION__ . '">
+					<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Tautulli...</h2></div>
+					<script>
+						// Tautulli
+						homepageTautulli("' . $this->config['homepageTautulliRefresh'] . '");
+						// End Tautulli
+					</script>
+				</div>
+				';
 		}
-		if (empty($this->config['tautulliApikey'])) {
-			$this->setAPIResponse('error', 'Tautulli Token is not defined', 422);
+	}
+	
+	public function getTautulliHomepageData()
+	{
+		if (!$this->homepageItemPermissions($this->tautulliHomepagePermissions('main'), true)) {
 			return false;
 		}
 		$api = [];
@@ -214,7 +250,8 @@ trait TautulliHomepageItem
 		$nowPlayingWidth = $this->getCacheImageSize('npw');
 		try {
 			$homestatsUrl = $apiURL . '&cmd=get_home_stats&grouping=1';
-			$homestats = Requests::get($homestatsUrl, [], []);
+			$options = $this->requestOptions($this->config['tautulliURL'], false, $this->config['homepageTautulliRefresh']);
+			$homestats = Requests::get($homestatsUrl, [], $options);
 			if ($homestats->success) {
 				$homestats = json_decode($homestats->body, true);
 				$api['homestats'] = $homestats['response'];
@@ -235,7 +272,8 @@ trait TautulliHomepageItem
 				$this->cacheImage($url . '/images/platforms/' . $platform . '.svg', 'tautulli-' . $platform, 'svg');
 			}
 			$libstatsUrl = $apiURL . '&cmd=get_libraries';
-			$libstats = Requests::get($libstatsUrl, [], []);
+			$options = $this->requestOptions($this->config['tautulliURL'], false, $this->config['homepageTautulliRefresh']);
+			$libstats = Requests::get($libstatsUrl, [], $options);
 			if ($libstats->success) {
 				$libstats = json_decode($libstats->body, true);
 				$api['libstats'] = $libstats['response'];
@@ -255,6 +293,7 @@ trait TautulliHomepageItem
 				'popularMovies' => $this->config['tautulliPopularMovies'],
 				'popularTV' => $this->config['tautulliPopularTV'],
 				'title' => $this->config['tautulliHeaderToggle'],
+				'friendlyName' => $this->config['tautulliFriendlyName'],
 			];
 			$ids = []; // Array of stat_ids to remove from the returned array
 			if (!$this->qualifyRequest($this->config['homepageTautulliLibraryAuth'])) {

+ 359 - 0
api/homepage/trakt.php

@@ -0,0 +1,359 @@
+<?php
+
+trait TraktHomepageItem
+{
+	public function traktSettingsArray()
+	{
+		return array(
+			'name' => 'Trakt',
+			'enabled' => strpos('personal', $this->config['license']) !== false,
+			'image' => 'plugins/images/tabs/trakt.png',
+			'category' => 'Calendar',
+			'docs' => 'https://docs.organizr.app/books/setup-features/page/trakt',
+			'settings' => array(
+				'About' => array(
+					array(
+						'type' => 'html',
+						'override' => 12,
+						'label' => '',
+						'html' => '
+							<div class="panel panel-default">
+								<div class="panel-wrapper collapse in">
+									<div class="panel-body">
+										<h3 lang="en">Trakt Homepage Item</h3>
+										<p lang="en">This homepage item enables the calendar on the homepage and displays your movies and/or tv shows from Trakt\'s API.</p>
+										<p lang="en">In order for this item to be setup, you need to goto the following URL to create a new API app.</p>
+										<p><a href="https://trakt.tv/oauth/applications/new" target="_blank">New API App</a></p>
+										<p lang="en">Enter anything for Name and Description.  You can leave Javascript and Permissions blank.  The only info you have to enter is for Redirect URI.  Enter the following URL:</p>
+										<code>' . $this->getServerPath() . 'api/v2/oauth/trakt</code>
+									</div>
+								</div>
+							</div>'
+					),
+				),
+				'Enable' => array(
+					array(
+						'type' => 'switch',
+						'name' => 'homepageTraktEnabled',
+						'label' => 'Enable',
+						'value' => $this->config['homepageTraktEnabled']
+					),
+					array(
+						'type' => 'select',
+						'name' => 'homepageTraktAuth',
+						'label' => 'Minimum Authentication',
+						'value' => $this->config['homepageTraktAuth'],
+						'options' => $this->groupOptions
+					)
+				),
+				'Connection' => array(
+					array(
+						'type' => 'input',
+						'name' => 'traktClientId',
+						'label' => 'Client Id',
+						'value' => $this->config['traktClientId'],
+						'help' => 'Please make sure to use local IP address and port - You also may use local dns name too.'
+					),
+					array(
+						'type' => 'password-alt',
+						'name' => 'traktClientSecret',
+						'label' => 'Client Secret',
+						'value' => $this->config['traktClientSecret']
+					),
+					array(
+						'type' => 'blank',
+						'label' => 'Please Save before clicking button'
+					),
+					array(
+						'type' => 'button',
+						'label' => '',
+						'icon' => 'fa fa-user',
+						'class' => 'pull-right',
+						'text' => 'Connect Account',
+						'attr' => 'onclick="openOAuth(\'trakt\')"'
+					),
+				),
+				'Calendar' => array(
+					array(
+						'type' => 'number',
+						'name' => 'calendarStartTrakt',
+						'label' => '# of Days Before',
+						'value' => $this->config['calendarStartTrakt'],
+						'placeholder' => '',
+						'help' => 'Total Days (Adding start and end days) has a maximum of 33 Days from Trakt API'
+					),
+					array(
+						'type' => 'number',
+						'name' => 'calendarEndTrakt',
+						'label' => '# of Days After',
+						'value' => $this->config['calendarEndTrakt'],
+						'placeholder' => '',
+						'help' => 'Total Days (Adding start and end days) has a maximum of 33 Days from Trakt API'
+					),
+					array(
+						'type' => 'select',
+						'name' => 'calendarFirstDay',
+						'label' => 'Start Day',
+						'value' => $this->config['calendarFirstDay'],
+						'options' => $this->daysOptions()
+					),
+					array(
+						'type' => 'select',
+						'name' => 'calendarDefault',
+						'label' => 'Default View',
+						'value' => $this->config['calendarDefault'],
+						'options' => $this->calendarDefaultOptions()
+					),
+					array(
+						'type' => 'select',
+						'name' => 'calendarTimeFormat',
+						'label' => 'Time Format',
+						'value' => $this->config['calendarTimeFormat'],
+						'options' => $this->timeFormatOptions()
+					),
+					array(
+						'type' => 'select',
+						'name' => 'calendarLocale',
+						'label' => 'Locale',
+						'value' => $this->config['calendarLocale'],
+						'options' => $this->calendarLocaleOptions()
+					),
+					array(
+						'type' => 'select',
+						'name' => 'calendarLimit',
+						'label' => 'Items Per Day',
+						'value' => $this->config['calendarLimit'],
+						'options' => $this->limitOptions()
+					),
+					array(
+						'type' => 'select',
+						'name' => 'calendarRefresh',
+						'label' => 'Refresh Seconds',
+						'value' => $this->config['calendarRefresh'],
+						'options' => $this->timeOptions()
+					)
+				)
+			)
+		);
+	}
+	
+	public function traktHomepagePermissions($key = null)
+	{
+		$permissions = [
+			'calendar' => [
+				'enabled' => [
+					'homepageTraktEnabled'
+				],
+				'auth' => [
+					'homepageTraktAuth'
+				],
+				'not_empty' => [
+					'traktClientId',
+					'traktAccessToken'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
+		}
+	}
+	
+	public function getTraktCalendar($startDate = null)
+	{
+		$startDate = date('Y-m-d', strtotime('-' . $this->config['calendarStartTrakt'] . ' days'));
+		$calendarItems = array();
+		$errors = null;
+		$totalDays = (int)$this->config['calendarStartTrakt'] + (int)$this->config['calendarEndTrakt'];
+		if (!$this->homepageItemPermissions($this->traktHomepagePermissions('calendar'), true)) {
+			return false;
+		}
+		$headers = [
+			'Content-Type' => 'application/json',
+			'Authorization' => 'Bearer ' . $this->config['traktAccessToken'],
+			'trakt-api-version' => 2,
+			'trakt-api-key' => $this->config['traktClientId']
+		];
+		$url = $this->qualifyURL('https://api.trakt.tv/calendars/my/shows/' . $startDate . '/' . $totalDays . '?extended=full');
+		$options = $this->requestOptions($url, false, $this->config['calendarRefresh']);
+		try {
+			$response = Requests::get($url, $headers, $options);
+			if ($response->success) {
+				$data = json_decode($response->body, true);
+				$traktTv = $this->formatTraktCalendarTv($data);
+				if (!empty($traktTv)) {
+					$calendarItems = array_merge($calendarItems, $traktTv);
+				}
+			}
+			
+		} catch (Requests_Exception $e) {
+			$this->writeLog('error', 'Trakt Connect Function - Error: ' . $e->getMessage(), 'SYSTEM');
+			$this->setAPIResponse('error', $e->getMessage(), 500);
+			$errors = true;
+		}
+		$url = $this->qualifyURL('https://api.trakt.tv/calendars/my/movies/' . $startDate . '/' . $totalDays . '?extended=full');
+		try {
+			$response = Requests::get($url, $headers, $options);
+			if ($response->success) {
+				$data = json_decode($response->body, true);
+				$traktMovies = $this->formatTraktCalendarMovies($data);
+				if (!empty($traktTv)) {
+					$calendarItems = array_merge($calendarItems, $traktMovies);
+				}
+			}
+		} catch (Requests_Exception $e) {
+			$this->writeLog('error', 'Trakt Connect Function - Error: ' . $e->getMessage(), 'SYSTEM');
+			$this->setAPIResponse('error', $e->getMessage(), 500);
+			$errors = true;
+		}
+		if ($errors) {
+			$this->setAPIResponse('error', 'An error Occurred', 500, null);
+			return false;
+		}
+		$this->setAPIResponse('success', null, 200, $calendarItems);
+		$this->traktOAuthRefresh();
+		return $calendarItems;
+	}
+	
+	public function formatTraktCalendarTv($array)
+	{
+		$gotCalendar = array();
+		$i = 0;
+		foreach ($array as $child) {
+			$i++;
+			$seriesName = $child['show']['title'];
+			$seriesID = $child['show']['ids']['tmdb'];
+			$episodeID = $child['show']['ids']['tmdb'];
+			if (!isset($episodeID)) {
+				$episodeID = "";
+			}
+			//$episodeName = htmlentities($child['title'], ENT_QUOTES);
+			$episodeAirDate = $child['first_aired'];
+			$episodeAirDate = strtotime($episodeAirDate);
+			$episodeAirDate = date("Y-m-d H:i:s", $episodeAirDate);
+			if (new DateTime() < new DateTime($episodeAirDate)) {
+				$unaired = true;
+			}
+			if ($child['episode']['number'] == 1) {
+				$episodePremier = "true";
+			} else {
+				$episodePremier = "false";
+				$date = new DateTime($episodeAirDate);
+				$date->add(new DateInterval("PT1S"));
+				$date->format(DateTime::ATOM);
+				$child['first_aired'] = gmdate('Y-m-d\TH:i:s\Z', strtotime($date->format(DateTime::ATOM)));
+			}
+			$downloaded = 0;
+			$monitored = 0;
+			if ($downloaded == "0" && isset($unaired) && $episodePremier == "true") {
+				$downloaded = "text-primary animated flash";
+			} elseif ($downloaded == "0" && isset($unaired) && $monitored == "0") {
+				$downloaded = "text-dark";
+			} elseif ($downloaded == "0" && isset($unaired)) {
+				$downloaded = "text-info";
+			} elseif ($downloaded == "1") {
+				$downloaded = "text-success";
+			} else {
+				$downloaded = "text-danger";
+			}
+			$fanart = "/plugins/images/cache/no-np.png";
+			$bottomTitle = 'S' . sprintf("%02d", $child['episode']['season']) . 'E' . sprintf("%02d", $child['episode']['number']) . ' - ' . $child['episode']['title'];
+			$details = array(
+				"seasonCount" => $child['episode']['season'],
+				"status" => 'dunno',
+				"topTitle" => $seriesName,
+				"bottomTitle" => $bottomTitle,
+				"overview" => isset($child['episode']['overview']) ? $child['episode']['overview'] : '',
+				"runtime" => isset($child['episode']['runtime']) ? $child['episode']['runtime'] : '',
+				"image" => $fanart,
+				"ratings" => isset($child['show']['rating']) ? $child['show']['rating'] : '',
+				"videoQuality" => "unknown",
+				"audioChannels" => "unknown",
+				"audioCodec" => "unknown",
+				"videoCodec" => "unknown",
+				"size" => "unknown",
+				"genres" => isset($child['show']['genres']) ? $child['show']['genres'] : '',
+			);
+			array_push($gotCalendar, array(
+				"id" => "Trakt-Tv-" . $i,
+				"title" => $seriesName,
+				"start" => $child['first_aired'],
+				"className" => "inline-popups bg-calendar calendar-item get-tmdb-image tmdb-tv tmdbID--" . $seriesID,
+				"imagetype" => "tv " . $downloaded,
+				"imagetypeFilter" => "tv",
+				"downloadFilter" => $downloaded,
+				"bgColor" => str_replace('text', 'bg', $downloaded),
+				"details" => $details
+			));
+		}
+		if ($i != 0) {
+			return $gotCalendar;
+		}
+		return false;
+	}
+	
+	public function formatTraktCalendarMovies($array)
+	{
+		$gotCalendar = array();
+		$i = 0;
+		foreach ($array as $child) {
+			$i++;
+			$movieName = $child['movie']['title'];
+			$movieID = $child['movie']['ids']['tmdb'];
+			if (!isset($movieID)) {
+				$movieID = '';
+			}
+			$physicalRelease = (isset($child['movie']['released']) ? $child['movie']['released'] : null);
+			//$backupRelease = (isset($child['info']['release_date']['theater']) ? $child['info']['release_date']['theater'] : null);
+			//$physicalRelease = (isset($physicalRelease) ? $physicalRelease : $backupRelease);
+			$physicalRelease = strtotime($physicalRelease);
+			$physicalRelease = date('Y-m-d', $physicalRelease);
+			$oldestDay = new DateTime ($this->currentTime);
+			$oldestDay->modify('-' . $this->config['calendarStart'] . ' days');
+			$newestDay = new DateTime ($this->currentTime);
+			$newestDay->modify('+' . $this->config['calendarEnd'] . ' days');
+			$startDt = new DateTime ($physicalRelease);
+			if (new DateTime() < $startDt) {
+				$notReleased = 'true';
+			} else {
+				$notReleased = 'false';
+			}
+			$downloaded = 'text-dark';
+			$banner = '/plugins/images/cache/no-np.png';
+			$details = array(
+				'topTitle' => $movieName,
+				'bottomTitle' => $child['movie']['tagline'],
+				'status' => $child['movie']['status'],
+				'overview' => $child['movie']['overview'],
+				'runtime' => $child['movie']['runtime'],
+				'image' => $banner,
+				'ratings' => isset($child['movie']['rating']) ? $child['movie']['rating'] : '',
+				'videoQuality' => 'unknown',
+				'audioChannels' => '',
+				'audioCodec' => '',
+				'videoCodec' => '',
+				'genres' => $child['movie']['genres'],
+				'year' => isset($child['movie']['year']) ? $child['movie']['year'] : '',
+				'studio' => isset($child['movie']['year']) ? $child['movie']['year'] : '',
+			);
+			array_push($gotCalendar, array(
+				'id' => 'Trakt-Movie-' . $i,
+				'title' => $movieName,
+				'start' => $physicalRelease,
+				'className' => 'inline-popups bg-calendar calendar-item get-tmdb-image tmdb-movie tmdbID--' . $movieID,
+				'imagetype' => 'film ' . $downloaded,
+				'imagetypeFilter' => 'film',
+				'downloadFilter' => $downloaded,
+				'bgColor' => str_replace('text', 'bg', $downloaded),
+				'details' => $details
+			));
+		}
+		if ($i != 0) {
+			return $gotCalendar;
+		}
+		return false;
+	}
+}

+ 56 - 13
api/homepage/transmission.php

@@ -34,6 +34,12 @@ trait TransmissionHomepageItem
 						'help' => 'Please do not included /web in URL.  Please make sure to use local IP address and port - You also may use local dns name too.',
 						'placeholder' => 'http(s)://hostname:port'
 					),
+					array(
+						'type' => 'switch',
+						'name' => 'transmissionDisableCertCheck',
+						'label' => 'Disable Certificate Check',
+						'value' => $this->config['transmissionDisableCertCheck']
+					),
 					array(
 						'type' => 'input',
 						'name' => 'transmissionUsername',
@@ -101,7 +107,7 @@ trait TransmissionHomepageItem
 		$passwordInclude = ($this->config['transmissionUsername'] != '' && $this->config['transmissionPassword'] != '') ? $this->config['transmissionUsername'] . ':' . $this->decrypt($this->config['transmissionPassword']) . "@" : '';
 		$url = $digest['scheme'] . '://' . $passwordInclude . $digest['host'] . $digest['port'] . $digest['path'] . '/rpc';
 		try {
-			$options = ($this->localURL($this->config['transmissionURL'])) ? array('verify' => false) : array();
+			$options = $this->requestOptions($this->config['transmissionURL'], $this->config['transmissionDisableCertCheck'], $this->config['homepageDownloadRefresh']);
 			$response = Requests::get($url, array(), $options);
 			if ($response->headers['x-transmission-session-id']) {
 				$headers = array(
@@ -137,25 +143,59 @@ trait TransmissionHomepageItem
 		}
 	}
 	
-	public function getTransmissionHomepageQueue()
+	public function transmissionHomepagePermissions($key = null)
 	{
-		if (!$this->config['homepageTransmissionEnabled']) {
-			$this->setAPIResponse('error', 'Transmission homepage item is not enabled', 409);
-			return false;
+		$permissions = [
+			'main' => [
+				'enabled' => [
+					'homepageTransmissionEnabled'
+				],
+				'auth' => [
+					'homepageTransmissionAuth'
+				],
+				'not_empty' => [
+					'transmissionURL'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
 		}
-		if (!$this->qualifyRequest($this->config['homepageTransmissionAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
+	}
+	
+	public function homepageOrdertransmission()
+	{
+		if ($this->homepageItemPermissions($this->transmissionHomepagePermissions('main'))) {
+			$loadingBox = ($this->config['transmissionCombine']) ? '' : '<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Download Queue...</h2></div>';
+			$builder = ($this->config['transmissionCombine']) ? 'buildDownloaderCombined(\'transmission\');' : '$("#' . __FUNCTION__ . '").html(buildDownloader("transmission"));';
+			return '
+				<div id="' . __FUNCTION__ . '">
+					' . $loadingBox . '
+					<script>
+		                // homepageOrdertransmission
+		                ' . $builder . '
+		                homepageDownloader("transmission", "' . $this->config['homepageDownloadRefresh'] . '");
+		                // End homepageOrdertransmission
+	                </script>
+				</div>
+				';
 		}
-		if (empty($this->config['transmissionURL'])) {
-			$this->setAPIResponse('error', 'Transmission URL is not defined', 422);
+	}
+	
+	public function getTransmissionHomepageQueue()
+	{
+		if (!$this->homepageItemPermissions($this->transmissionHomepagePermissions('main'), true)) {
 			return false;
 		}
 		$digest = $this->qualifyURL($this->config['transmissionURL'], true);
 		$passwordInclude = ($this->config['transmissionUsername'] != '' && $this->config['transmissionPassword'] != '') ? $this->config['transmissionUsername'] . ':' . $this->decrypt($this->config['transmissionPassword']) . "@" : '';
 		$url = $digest['scheme'] . '://' . $passwordInclude . $digest['host'] . $digest['port'] . $digest['path'] . '/rpc';
 		try {
-			$options = ($this->localURL($this->config['transmissionURL'])) ? array('verify' => false) : array();
+			$options = $this->requestOptions($this->config['transmissionURL'], $this->config['transmissionDisableCertCheck'], $this->config['homepageDownloadRefresh']);
 			$response = Requests::get($url, array(), $options);
 			if ($response->headers['x-transmission-session-id']) {
 				$headers = array(
@@ -166,7 +206,7 @@ trait TransmissionHomepageItem
 					'method' => 'torrent-get',
 					'arguments' => array(
 						'fields' => array(
-							"id", "name", "totalSize", "eta", "isFinished", "isStalled", "percentDone", "rateDownload", "status", "downloadDir", "errorString"
+							"id", "name", "totalSize", "eta", "isFinished", "isStalled", "percentDone", "rateDownload", "status", "downloadDir", "errorString", "addedDate"
 						),
 					),
 					'tags' => ''
@@ -189,8 +229,11 @@ trait TransmissionHomepageItem
 							}
 						}
 					} else {
-						$torrents = json_decode($response->body, true);
+						$torrents = json_decode($response->body, true)['arguments']['torrents'];
 					}
+					usort($torrents, function ($a, $b) {
+						return $a["addedDate"] < $b["addedDate"];
+					});
 					$api['content']['queueItems'] = $torrents;
 					$api['content']['historyItems'] = false;
 				}

+ 51 - 26
api/homepage/unifi.php

@@ -5,9 +5,9 @@ trait UnifiHomepageItem
 	public function unifiSettingsArray()
 	{
 		return array(
-			'name' => 'Unifi',
+			'name' => 'UniFi',
 			'enabled' => true,
-			'image' => 'plugins/images/tabs/ubnt.png',
+			'image' => 'plugins/images/tabs/unifi.png',
 			'category' => 'Monitor',
 			'settings' => array(
 				'Enable' => array(
@@ -92,7 +92,49 @@ trait UnifiHomepageItem
 			)
 		);
 	}
-	
+
+	public function unifiHomepagePermissions($key = null)
+	{
+		$permissions = [
+			'main' => [
+				'enabled' => [
+					'homepageUnifiEnabled'
+				],
+				'auth' => [
+					'homepageUnifiAuth'
+				],
+				'not_empty' => [
+					'unifiURL',
+					'unifiUsername',
+					'unifiPassword'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
+		}
+	}
+
+	public function homepageOrderunifi()
+	{
+		if ($this->homepageItemPermissions($this->unifiHomepagePermissions('main'))) {
+			return '
+				<div id="' . __FUNCTION__ . '">
+					<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Unifi...</h2></div>
+					<script>
+						// Unifi
+						homepageUnifi("' . $this->config['homepageHealthChecksRefresh'] . '");
+						// End Unifi
+					</script>
+				</div>
+				';
+		}
+	}
+
 	public function getUnifiSiteName()
 	{
 		if (empty($this->config['unifiURL'])) {
@@ -141,9 +183,9 @@ trait UnifiHomepageItem
 			$this->setAPIResponse('error', $e->getMessage(), 500);
 			return false;
 		}
-		
+
 	}
-	
+
 	public function testConnectionUnifi()
 	{
 		if (empty($this->config['unifiURL'])) {
@@ -185,7 +227,7 @@ trait UnifiHomepageItem
 				$cookie['csrf_token'] = ($response->cookies['csrf_token']->value) ?? false;
 				$cookie['Token'] = ($response->cookies['Token']->value) ?? false;
 				$options['cookies'] = $response->cookies;
-				
+
 			} else {
 				$this->setAPIResponse('error', 'Unifi response error - Check Credentials', 409);
 				return false;
@@ -209,27 +251,10 @@ trait UnifiHomepageItem
 		$this->setAPIResponse('success', 'API Connection succeeded', 200);
 		return true;
 	}
-	
+
 	public function getUnifiHomepageData()
 	{
-		if (!$this->config['homepageUnifiEnabled']) {
-			$this->setAPIResponse('error', 'Unifi homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageUnifiAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (empty($this->config['unifiURL'])) {
-			$this->setAPIResponse('error', 'Unifi URL is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['unifiUsername'])) {
-			$this->setAPIResponse('error', 'Unifi Username is not defined', 422);
-			return false;
-		}
-		if (empty($this->config['unifiPassword'])) {
-			$this->setAPIResponse('error', 'Unifi Password is not defined', 422);
+		if (!$this->homepageItemPermissions($this->unifiHomepagePermissions('main'), true)) {
 			return false;
 		}
 		$api['content']['unifi'] = array();
@@ -259,7 +284,7 @@ trait UnifiHomepageItem
 				$cookie['csrf_token'] = ($response->cookies['csrf_token']->value) ?? false;
 				$cookie['Token'] = ($response->cookies['Token']->value) ?? false;
 				$options['cookies'] = $response->cookies;
-				
+
 			} else {
 				$this->setAPIResponse('error', 'Unifi response error - Check Credentials', 409);
 				return false;

+ 43 - 11
api/homepage/weather.php

@@ -117,6 +117,47 @@ trait WeatherHomepageItem
 		);
 	}
 	
+	public function weatherHomepagePermissions($key = null)
+	{
+		$permissions = [
+			'main' => [
+				'enabled' => [
+					'homepageWeatherAndAirEnabled'
+				],
+				'auth' => [
+					'homepageWeatherAndAirAuth'
+				],
+				'not_empty' => [
+					'homepageWeatherAndAirLatitude',
+					'homepageWeatherAndAirLongitude'
+				]
+			]
+		];
+		if (array_key_exists($key, $permissions)) {
+			return $permissions[$key];
+		} elseif ($key == 'all') {
+			return $permissions;
+		} else {
+			return [];
+		}
+	}
+	
+	public function homepageOrderWeatherAndAir()
+	{
+		if ($this->homepageItemPermissions($this->weatherHomepagePermissions('main'))) {
+			return '
+				<div id="' . __FUNCTION__ . '">
+					<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Weather...</h2></div>
+					<script>
+						// Weather And Air
+						homepageWeatherAndAir("' . $this->config['homepageWeatherAndAirRefresh'] . '");
+						// End Weather And Air
+					</script>
+				</div>
+				';
+		}
+	}
+	
 	public function searchCityForCoordinates($query)
 	{
 		try {
@@ -140,16 +181,7 @@ trait WeatherHomepageItem
 	
 	public function getWeatherAndAirData()
 	{
-		if (!$this->config['homepageWeatherAndAirEnabled']) {
-			$this->setAPIResponse('error', 'Weather homepage item is not enabled', 409);
-			return false;
-		}
-		if (!$this->qualifyRequest($this->config['homepageWeatherAndAirAuth'])) {
-			$this->setAPIResponse('error', 'User not approved to view this homepage item', 401);
-			return false;
-		}
-		if (empty($this->config['homepageWeatherAndAirLatitude']) && empty($this->config['homepageWeatherAndAirLongitude'])) {
-			$this->setAPIResponse('error', 'Weather Latitude and/or Longitude were not defined', 422);
+		if (!$this->homepageItemPermissions($this->weatherHomepagePermissions('main'), true)) {
 			return false;
 		}
 		$api['content'] = array(
@@ -158,7 +190,7 @@ trait WeatherHomepageItem
 			'pollen' => false
 		);
 		$apiURL = $this->qualifyURL('https://api.breezometer.com/');
-		$info = '&lat=' . $this->config['homepageWeatherAndAirLatitude'] . '&lon=' . $this->config['homepageWeatherAndAirLongitude'] . '&units=' . $this->config['homepageWeatherAndAirUnits'] . '&key=b7401295888443538a7ebe04719c8394';
+		$info = '&lat=' . $this->config['homepageWeatherAndAirLatitude'] . '&lon=' . $this->config['homepageWeatherAndAirLongitude'] . '&units=' . $this->config['homepageWeatherAndAirUnits'] . '&key=' . $this->config['breezometerToken'];
 		try {
 			$headers = array();
 			$options = array();

+ 3 - 39
api/pages/homepage.php

@@ -39,7 +39,7 @@ function get_page_homepage($Organizr = null)
 			    filterCalendar: {
 			      text: \'Filter\',
 			      click: function() {
-			        $(\'#calendar-filter-modal\').modal(\'show\');
+			        toggleCalendarFilter();
 			      },
 			      //icon: \'x\'
 			    },
@@ -96,7 +96,7 @@ function($) {
     "use strict";
     $.CalendarApp.init()
 }(window.jQuery);
-$(".homepage-loading-box").fadeOut(1000);
+$(".homepage-loading-box").fadeOut(5000);
 </script>
 <div class="container-fluid p-t-30" id="homepage-items">
     ' . $Organizr->buildHomepage() . '
@@ -105,42 +105,6 @@ $(".homepage-loading-box").fadeOut(1000);
     <div class="col-md-8 col-md-offset-2 youtube-div">  </div>
 </div>
 <!-- /.container-fluid -->
-<!--  modal content -->
-<div id="calendar-filter-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="mySmallModalLabel" aria-hidden="true" style="display: none;">
-    <div class="modal-dialog modal-sm">
-        <div class="modal-content">
-            <div class="modal-header">
-                <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
-                <h4 class="modal-title" id="mySmallModalLabel" lane="en">Filter Calendar</h4> </div>
-            <div class="modal-body">
-            	<div class="row">
-                    
-                    <div class="col-md-12">
-                        <label class="control-label" lang="en">Choose Media Type</label>
-                        <select class="form-control form-white" data-placeholder="Choose media type" id="choose-calender-filter">
-                            <option value="all" lang="en">All</option>
-                            <option value="tv" lang="en">TV</option>
-                            <option value="film" lang="en">Movie</option>
-                            <option value="music" lang="en">Music</option>
-                        </select>
-                    </div>
-                    <div class="col-md-12">
-                        <label class="control-label" lang="en">Choose Media Status</label>
-                        <select class="form-control form-white" data-placeholder="Choose media status" id="choose-calender-filter-status">
-                            <option value="all" lang="en">All</option>
-                            <option value="text-success" lang="en">Downloaded</option>
-                            <option value="text-info" lang="en">Unaired</option>
-                            <option value="text-danger" lang="en">Missing</option>
-                            <option value="text-primary animated flash" lang="en">Premier</option>
-                        </select>
-                    </div>
-                </div>
-			</div>
-        </div>
-        <!-- /.modal-content -->
-    </div>
-    <!-- /.modal-dialog -->
-</div>
-<!-- /.modal -->
+
 ';
 }

+ 143 - 140
api/pages/login.php

@@ -11,6 +11,9 @@ function get_page_login($Organizr)
 	$hideOrganizrLogin = ($Organizr->checkoAuth()) ? 'collapse' : 'collapse in';
 	$hideOrganizrLoginHeader = ($Organizr->checkoAuthOnly()) ? 'hidden' : '';
 	$hideOrganizrLoginHeader2 = ($Organizr->checkoAuth()) ? '' : 'hidden';
+	$hideOrganizrRecoveryPassword = ($Organizr->config['disableRecoverPass']) ? 'hidden' : '';
+	$customForgotPasswordText = (empty($Organizr->config['customForgotPassText'])) ? 'Enter your Email and instructions will be sent to you!' : $Organizr->config['customForgotPassText'];
+	$customForgotPasswordText = ($Organizr->config['disableRecoverPass']) ? 'Disabled' : $customForgotPasswordText;
 	return '
 <script>
 if(activeInfo.settings.login.rememberMe){
@@ -18,145 +21,145 @@ if(activeInfo.settings.login.rememberMe){
 }
 </script>
 <section id="wrapper" class="login-register">
-  <div class="login-box login-sidebar animated slideInRight">
-    <div class="white-box">
-      <form class="form-horizontal" id="loginform" onsubmit="return false;">
-      	<input id="login-attempts" class="form-control" name="loginAttempts" type="hidden">
-        <a href="javascript:void(0)" class="text-center db visible-xs" id="login-logo">' . $Organizr->logoOrText() . '</a>
-        <div id="oAuth-div" class="form-group hidden">
-          <div class="col-xs-12">
-            <div class="panel panel-success animated tada">
-                <div class="panel-heading">oAuth Successful - Please wait...</div>
-            </div>
-          </div>
-        </div>
-		<div id="tfa-div" class="form-group hidden">
-          <div class="col-xs-12">
-            <div class="panel panel-warning animated tada">
-                <div class="panel-heading"> 2FA
-                    <div class="pull-right"><a href="#" data-perform="panel-collapse"><i class="ti-minus"></i></a> <a href="#" data-perform="panel-dismiss"><i class="ti-close"></i></a> </div>
-                </div>
-                <div class="panel-wrapper collapse in" aria-expanded="true">
-                    <div class="panel-body">
-	                    <div class="input-group" style="width: 100%;">
-	                        <div class="input-group-addon hidden-xs"><i class="ti-lock"></i></div>
-	                        <input type="text" class="form-control tfa-input" name="tfaCode" placeholder="Code" data-lpignore="true" autocomplete="off" autocorrect="off" autocapitalize="off" maxlength="6" spellcheck="false" autofocus="">
-	                    </div>
-	                    <button class="btn btn-warning btn-lg btn-block text-uppercase waves-effect waves-light login-button m-t-10" type="submit" lang="en">Login</button>
-                    </div>
-                </div>
-            </div>
-          </div>
-        </div>
-        <div class="panel-group" id="login-panels" data-type="accordion" aria-multiselectable="true" role="tablist">
-	        <!-- ORGANIZR LOGIN -->
-	        <div class="panel">
-	            <div class="panel-heading bg-org ' . $hideOrganizrLoginHeader . ' ' . $hideOrganizrLoginHeader2 . '" id="organizr-login-heading" role="tab">
-	            	<a class="panel-title collapsed" data-toggle="collapse" href="#organizr-login-collapse" data-parent="#login-panels" aria-expanded="false" aria-controls="organizr-login-collapse">
-                        <img class="lazyload loginTitle" data-src="plugins/images/organizr/logo-no-border.png"> &nbsp;
-                        <span class="text-uppercase fw300" lang="en">Login with Organizr</span>
-	            	</a>
-	            	<div class="clearfix"></div>
-	            </div>
-	            <div class="panel-collapse ' . $hideOrganizrLogin . '" id="organizr-login-collapse" aria-labelledby="organizr-login-heading" role="tabpanel">
-	                <div class="panel-body">
-	                
-	                	<div class="form-group">
-				          <div class="col-xs-12">
-				            <input id="login-username-Input" class="form-control" name="username" type="text" required="" placeholder="Username" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" lang="en" autofocus>
-				          </div>
-				        </div>
-				        <div class="form-group">
-				          <div class="col-xs-12">
-				            <input id="login-password-Input" class="form-control" name="password" type="password" required="" placeholder="Password" lang="en">
-				          </div>
-				        </div>
-				        <div class="form-group">
-				          <div class="col-md-12">
-				            <div class="checkbox checkbox-primary pull-left p-t-0 remember-me">
-				              <input id="checkbox-login" name="remember" type="checkbox">
-				              <label for="checkbox-login" lang="en">Remember Me</label>
-				            </div>
-				            </div>
-				        </div>
-				        <div class="form-group text-center m-t-20 m-b-0">
-				          <div class="col-xs-12">
-				            <button class="btn btn-info btn-lg btn-block text-uppercase waves-effect waves-light login-button" type="submit" lang="en">Login</button>
-				          </div>
-				        </div>
-				        <div class="form-group m-b-0">
-				          <div class="col-sm-12 text-center">
-				            <input id="oAuth-Input" class="form-control" name="oAuth" type="hidden">
-				            <input id="oAuthType-Input" class="form-control" name="oAuthType" type="hidden">
-				            ' . $Organizr->showLogin() . '
-				          </div>
-				        </div>
-	                </div>
-	            </div>
-	        </div>
-	        <!-- END ORGANIZR LOGIN -->
-        	<!-- PLEX OAUTH LOGIN -->
-	        ' . $Organizr->showoAuth() . '
-	        <!-- END PLEX OAUTH LOGIN -->
-        </div>
-      </form>
-      <form class="form-horizontal form-material hidden" id="registerForm" onsubmit="return false;">
-        <div class="form-group m-t-40">
-          <div class="col-xs-12">
-            <input class="form-control" type="text" name="registrationPassword" required="" placeholder="Registration Password" lang="en" autofocus>
-          </div>
-        </div>
-        <div class="form-group">
-          <div class="col-xs-12">
-            <input class="form-control" name="username" type="text" required="" placeholder="Username" lang="en">
-          </div>
-        </div>
-        <div class="form-group">
-          <div class="col-xs-12">
-            <input class="form-control" name="email" type="text" required="" placeholder="Email" lang="en">
-          </div>
-        </div>
-        <div class="form-group">
-          <div class="col-xs-12">
-            <input class="form-control" name="password" type="password" required="" placeholder="Password" lang="en">
-          </div>
-        </div>
-        <div class="form-group text-center m-t-20">
-          <div class="col-xs-12">
-            <button class="btn btn-info btn-lg btn-block text-uppercase waves-effect waves-light register-button" type="submit" lang="en">Register</button>
-          </div>
-        </div>
-        <div class="form-group text-center m-t-20">
-          <div class="col-xs-12">
-            <button id="leave-registration" class="btn btn-primary btn-lg btn-block text-uppercase waves-effect waves-light" type="button" lang="en">Go Back</button>
-          </div>
-        </div>
-      </form>
-      <form class="form-horizontal" id="recoverform" onsubmit="return false;">
-        <div class="form-group ">
-          <div class="col-xs-12">
-            <h3 lang="en">Recover Password</h3>
-            <p class="text-muted" lang="en">Enter your Email and instructions will be sent to you!</p>
-          </div>
-        </div>
-        <div class="form-group ">
-          <div class="col-xs-12">
-            <input id="recover-input" class="form-control" name="email" type="text" placeholder="Email" lang="en" required>
-          </div>
-        </div>
-        <div class="form-group text-center m-t-20">
-          <div class="col-xs-12">
-            <button class="btn btn-primary btn-lg btn-block text-uppercase waves-effect waves-light reset-button" type="submit" lang="en">Reset</button>
-          </div>
-        </div>
-        <div class="form-group text-center m-t-20">
-          <div class="col-xs-12">
-            <button id="leave-recover" class="btn btn-primary btn-lg btn-block text-uppercase waves-effect waves-light" type="button" lang="en">Go Back</button>
-          </div>
-        </div>
-      </form>
-    </div>
-  </div>
+	<div class="login-box login-sidebar animated slideInRight">
+		<div class="white-box">
+			<form class="form-horizontal" id="loginform" onsubmit="return false;">
+				<input id="login-attempts" class="form-control" name="loginAttempts" type="hidden">
+				<a href="javascript:void(0)" class="text-center db visible-xs" id="login-logo">' . $Organizr->logoOrText() . '</a>
+				<div id="oAuth-div" class="form-group hidden">
+					<div class="col-xs-12">
+						<div class="panel panel-success animated tada">
+							<div class="panel-heading">oAuth Successful - Please wait...</div>
+						</div>
+					</div>
+				</div>
+				<div id="tfa-div" class="form-group hidden">
+				  <div class="col-xs-12">
+					<div class="panel panel-warning animated tada">
+						<div class="panel-heading"> 2FA
+							<div class="pull-right"><a href="#" data-perform="panel-collapse"><i class="ti-minus"></i></a> <a href="#" data-perform="panel-dismiss"><i class="ti-close"></i></a> </div>
+						</div>
+						<div class="panel-wrapper collapse in" aria-expanded="true">
+							<div class="panel-body">
+								<div class="input-group" style="width: 100%;">
+									<div class="input-group-addon hidden-xs"><i class="ti-lock"></i></div>
+									<input type="text" class="form-control tfa-input" name="tfaCode" placeholder="Code" data-lpignore="true" autocomplete="off" autocorrect="off" autocapitalize="off" maxlength="6" spellcheck="false" autofocus="">
+								</div>
+								<button class="btn btn-warning btn-lg btn-block text-uppercase waves-effect waves-light login-button m-t-10" type="submit" lang="en">Login</button>
+							</div>
+						</div>
+					</div>
+				  </div>
+				</div>
+				<div class="panel-group" id="login-panels" data-type="accordion" aria-multiselectable="true" role="tablist">
+					<!-- ORGANIZR LOGIN -->
+					<div class="panel">
+						<div class="panel-heading bg-org ' . $hideOrganizrLoginHeader . ' ' . $hideOrganizrLoginHeader2 . '" id="organizr-login-heading" role="tab">
+							<a class="panel-title collapsed" data-toggle="collapse" href="#organizr-login-collapse" data-parent="#login-panels" aria-expanded="false" aria-controls="organizr-login-collapse">
+								<img class="lazyload loginTitle" data-src="plugins/images/organizr/logo-no-border.png"> &nbsp;
+								<span class="text-uppercase fw300" lang="en">Login with Organizr</span>
+							</a>
+							<div class="clearfix"></div>
+						</div>
+						<div class="panel-collapse ' . $hideOrganizrLogin . '" id="organizr-login-collapse" aria-labelledby="organizr-login-heading" role="tabpanel">
+							<div class="panel-body">
+							
+								<div class="form-group">
+									<div class="col-xs-12">
+										<input id="login-username-Input" class="form-control" name="username" type="text" required="" placeholder="Username" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" lang="en" autofocus>
+									</div>
+								</div>
+								<div class="form-group">
+									<div class="col-xs-12">
+										<input id="login-password-Input" class="form-control" name="password" type="password" required="" placeholder="Password" lang="en">
+									</div>
+								</div>
+								<div class="form-group">
+									<div class="col-md-12">
+										<div class="checkbox checkbox-primary pull-left p-t-0 remember-me">
+											<input id="checkbox-login" name="remember" type="checkbox">
+											<label for="checkbox-login" lang="en">Remember Me</label>
+										</div>
+									</div>
+								</div>
+								<div class="form-group text-center m-t-20 m-b-0">
+									<div class="col-xs-12">
+										<button class="btn btn-info btn-lg btn-block text-uppercase waves-effect waves-light login-button" type="submit" lang="en">Login</button>
+									</div>
+								</div>
+								<div class="form-group m-b-0">
+									<div class="col-sm-12 text-center">
+										<input id="oAuth-Input" class="form-control" name="oAuth" type="hidden">
+										<input id="oAuthType-Input" class="form-control" name="oAuthType" type="hidden">
+										' . $Organizr->showLogin() . '
+									</div>
+								</div>
+							</div>
+						</div>
+					</div>
+					<!-- END ORGANIZR LOGIN -->
+					<!-- PLEX OAUTH LOGIN -->
+					' . $Organizr->showoAuth() . '
+					<!-- END PLEX OAUTH LOGIN -->
+				</div>
+			</form>
+			<form class="form-horizontal form-material hidden" id="registerForm" onsubmit="return false;">
+				<div class="form-group m-t-40">
+					<div class="col-xs-12">
+						<input class="form-control" type="text" name="registrationPassword" required="" placeholder="Registration Password" lang="en" autofocus>
+					</div>
+				</div>
+				<div class="form-group">
+					<div class="col-xs-12">
+						<input class="form-control" name="username" type="text" required="" placeholder="Username" lang="en">
+					</div>
+				</div>
+				<div class="form-group">
+					<div class="col-xs-12">
+						<input class="form-control" name="email" type="text" required="" placeholder="Email" lang="en">
+					</div>
+				</div>
+				<div class="form-group">
+					<div class="col-xs-12">
+						<input class="form-control" name="password" type="password" required="" placeholder="Password" lang="en">
+					</div>
+				</div>
+				<div class="form-group text-center m-t-20">
+					<div class="col-xs-12">
+						<button class="btn btn-info btn-lg btn-block text-uppercase waves-effect waves-light register-button" type="submit" lang="en">Register</button>
+					</div>
+				</div>
+				<div class="form-group text-center m-t-20">
+					<div class="col-xs-12">
+						<button id="leave-registration" class="btn btn-primary btn-lg btn-block text-uppercase waves-effect waves-light" type="button" lang="en">Go Back</button>
+					</div>
+				</div>
+			</form>
+			<form class="form-horizontal" id="recoverform" onsubmit="return false;">
+				<div class="form-group ">
+					<div class="col-xs-12">
+						<h3 lang="en">Recover Password</h3>
+						<p class="text-muted" lang="en">' . $customForgotPasswordText . '</p>
+					</div>
+				</div>
+				<div class="form-group ' . $hideOrganizrRecoveryPassword . '">
+					<div class="col-xs-12">
+						<input id="recover-input" class="form-control" name="email" type="text" placeholder="Email" lang="en" required>
+					</div>
+				</div>
+				<div class="form-group text-center m-t-20 ' . $hideOrganizrRecoveryPassword . '">
+					<div class="col-xs-12">
+						<button class="btn btn-primary btn-lg btn-block text-uppercase waves-effect waves-light reset-button" type="submit" lang="en">Reset</button>
+					</div>
+				</div>
+				<div class="form-group text-center m-t-20">
+					<div class="col-xs-12">
+						<button id="leave-recover" class="btn btn-primary btn-lg btn-block text-uppercase waves-effect waves-light" type="button" lang="en">Go Back</button>
+					</div>
+				</div>
+			</form>
+		</div>
+	</div>
 </section>
 ';
-}
+}

+ 3 - 1
api/pages/settings-image-manager.php

@@ -32,7 +32,9 @@ function get_page_settings_image_manager($Organizr)
 	</div>
     <div class="panel-wrapper collapse in" aria-expanded="true">
         <div class="panel-body bg-org" >
-        <div class="row el-element-overlay m-b-40" id="settings-image-manager-list"></div>
+        	<div id="gallery-content">
+                <div id="gallery-content-center" class="settings-image-manager-list"></div>
+            </div>
         </div>
     </div>
 </div>

+ 55 - 0
api/pages/settings-settings-backup.php

@@ -0,0 +1,55 @@
+<?php
+$GLOBALS['organizrPages'][] = 'settings_settings_backup';
+function get_page_settings_settings_backup($Organizr)
+{
+	if (!$Organizr) {
+		$Organizr = new Organizr();
+	}
+	if ((!$Organizr->hasDB())) {
+		return false;
+	}
+	if (!$Organizr->qualifyRequest(1, true)) {
+		return false;
+	}
+	return '
+    <script>
+		getOrganizrBackups();
+    </script>
+ 
+    <div class="white-box bg-org">
+		<div class="col-md-3 col-sm-4 col-xs-6 pull-right">
+			<button onclick="createOrganizrBackup()" class="btn btn-sm btn-info btn-rounded waves-effect waves-light pull-right" type="button"><span class="btn-label"><i class="fa ti-export"></i></span><span lang="en">Create Backup</span></button>
+		</div>
+		<h3 class="box-title" lang="en">Backup Organizr</h3>
+		<div class="row sales-report">
+			<div class="col-md-6 col-sm-6 col-xs-6">
+				<h2 id="backup-total-files"><i class="fa fa-spin fa-spinner"></i></h2>
+				<p lang="en">Files</p>
+			</div>
+			<div class="col-md-6 col-sm-6 col-xs-6 ">
+				<h1 class="text-right text-info m-t-20" id="backup-total-size"><i class="fa fa-spin fa-spinner"></i></h1>
+			</div>
+		</div>
+		<div class="table-responsive">
+			<table class="table">
+				<thead>
+					<tr>
+						<th>#</th>
+						<th lang="en">Name</th>
+						<th lang="en">Version</th>
+						<th lang="en">Size</th>
+						<th lang="en">Date</th>
+						<th lang="en">Action</th>
+					</tr>
+				</thead>
+				<tbody id="backup-file-list">
+					<tr>
+						<td class="text-center" colspan="6"><i class="fa fa-spin fa-spinner"></i></td>
+					</tr>
+				</tbody>
+			</table>
+		</div>
+	</div>
+    <!-- /.container-fluid -->
+    ';
+}

+ 0 - 31
api/pages/settings-settings-sso.php

@@ -26,36 +26,5 @@ function get_page_settings_settings_sso($Organizr)
         </div>
     </div>
 </div>
-<form id="sso-plex-token-form" class="mfp-hide white-popup-block mfp-with-anim">
-    <h1 lang="en">Get Plex Token</h1>
-    <div class="panel ssoPlexTokenHeader">
-        <div class="panel-heading ssoPlexTokenMessage" lang="en">Enter Plex Details</div>
-    </div>
-    <fieldset style="border:0;">
-        <div class="form-group">
-            <label class="control-label" for="sso-plex-token-form-username" lang="en">Plex Username</label>
-            <input type="text" class="form-control" id="sso-plex-token-form-username" name="username" required="" autofocus>
-        </div>
-        <div class="form-group">
-            <label class="control-label" for="sso-plex-token-form-password" lang="en">Plex Password</label>
-            <input type="password" class="form-control" id="sso-plex-token-form-password" name="password"  required="">
-        </div>
-    </fieldset>
-    <button class="btn btn-sm btn-info btn-rounded waves-effect waves-light pull-right row b-none getSSOPlexToken" type="button"><span class="btn-label"><i class="fa fa-ticket"></i></span><span lang="en">Grab It</span></button>
-    <div class="clearfix"></div>
-</form>
-<form id="sso-plex-machine-form" class="mfp-hide white-popup-block mfp-with-anim">
-    <h1 lang="en">Get Plex Machine</h1>
-    <div class="panel ssoPlexMachineHeader">
-        <div class="panel-heading ssoPlexMachineMessage" lang="en"></div>
-    </div>
-    <fieldset style="border:0;">
-        <div class="form-group">
-            <label class="control-label" for="sso-plex-machine-form-machine" lang="en">Plex Machine</label>
-            <div class="ssoPlexMachineListing"></div>
-        </div>
-    </fieldset>
-    <div class="clearfix"></div>
-</form>
 ';
 }

+ 131 - 70
api/pages/settings-tab-editor-categories.php

@@ -11,86 +11,147 @@ function get_page_settings_tab_editor_categories($Organizr)
 	if (!$Organizr->qualifyRequest(1, true)) {
 		return false;
 	}
+	$iconSelectors = '
+		$(".categoryIconIconList").select2({
+			ajax: {
+				url: \'api/v2/icon\',
+				data: function (params) {
+					var query = {
+						search: params.term,
+						page: params.page || 1
+					}
+					return query;
+				},
+				processResults: function (data, params) {
+					params.page = params.page || 1;
+					return {
+						results: data.response.data.results,
+						pagination: {
+							more: (params.page * 20) < data.response.data.total
+						}
+					};
+				},
+				//cache: true
+			},
+			placeholder: \'Search for an icon\',
+			templateResult: formatIcon,
+			templateSelection: formatIcon
+		});
+		
+		$(".categoryIconImageList").select2({
+			 ajax: {
+				url: \'api/v2/image/select\',
+				data: function (params) {
+					var query = {
+						search: params.term,
+						page: params.page || 1
+					}
+					return query;
+				},
+				processResults: function (data, params) {
+					params.page = params.page || 1;
+					return {
+						results: data.response.data.results,
+						pagination: {
+							more: (params.page * 20) < data.response.data.total
+						}
+					};
+				},
+				//cache: true
+			},
+			placeholder: \'Search for an image\',
+			templateResult: formatImage,
+			templateSelection: formatImage
+		});
+	';
 	return '
 <script>
 buildCategoryEditor();
 $( \'#categoryEditorTable\' ).sortable({
-    stop: function () {
-        var inputs = $(\'input.order\');
-        var nbElems = inputs.length;
-        inputs.each(function(idx) {
-            $(this).val(idx + 1);
-        });
-        submitCategoryOrder();
-    }
+	stop: function () {
+		var inputs = $(\'input.order\');
+		var nbElems = inputs.length;
+		inputs.each(function(idx) {
+			$(this).val(idx + 1);
+		});
+		submitCategoryOrder();
+	}
 });
+' . $iconSelectors . '
 </script>
 <div class="panel bg-org panel-info">
-    <div class="panel-heading">
-        <span lang="en">Category Editor</span>
-        <button type="button" class="btn btn-success btn-circle pull-right popup-with-form m-r-5" href="#new-category-form" data-effect="mfp-3d-unfold"><i class="fa fa-plus"></i> </button>
-    </div>
-    <div class="table-responsive">
-        <form id="submit-categories-form" onsubmit="return false;">
-            <table class="table table-hover manage-u-table">
-                <thead>
-                    <tr>
-                        <th width="70" class="text-center">#</th>
-                        <th lang="en">NAME</th>
-                        <th lang="en" style="text-align:center">TABS</th>
-                        <th lang="en" style="text-align:center">DEFAULT</th>
-                        <th lang="en" style="text-align:center">EDIT</th>
-                        <th lang="en" style="text-align:center">DELETE</th>
-                    </tr>
-                </thead>
-                <tbody id="categoryEditorTable"></tbody>
-            </table>
-        </form>
-    </div>
+	<div class="panel-heading">
+		<span lang="en">Category Editor</span>
+		<button type="button" class="btn btn-success btn-circle pull-right popup-with-form m-r-5" href="#new-category-form" data-effect="mfp-3d-unfold"><i class="fa fa-plus"></i> </button>
+	</div>
+	<div class="table-responsive">
+		<form id="submit-categories-form" onsubmit="return false;">
+			<table class="table table-hover manage-u-table">
+				<thead>
+					<tr>
+						<th width="70" class="text-center">#</th>
+						<th lang="en">NAME</th>
+						<th lang="en" style="text-align:center">TABS</th>
+						<th lang="en" style="text-align:center">DEFAULT</th>
+						<th lang="en" style="text-align:center">EDIT</th>
+						<th lang="en" style="text-align:center">DELETE</th>
+					</tr>
+				</thead>
+				<tbody id="categoryEditorTable"><td class="text-center" colspan="6"><i class="fa fa-spin fa-spinner"></i></td></tbody>
+			</table>
+		</form>
+	</div>
 </div>
 <form id="new-category-form" class="mfp-hide white-popup-block mfp-with-anim">
-    <h1 lang="en">Add New Category</h1>
-    <fieldset style="border:0;">
-        <div class="form-group">
-            <label class="control-label" for="new-category-form-inputNameNew" lang="en">Category Name</label>
-            <input type="text" class="form-control" id="new-category-form-inputNameNew" name="category" required="" autofocus>
-        </div>
-        <div class="form-group">
-            <label class="control-label" for="new-category-form-inputImageNew" lang="en">Category Image</label>
-            <input type="text" class="form-control" id="new-category-form-inputImageNew" name="image"  required="">
-        </div>
-    </fieldset>
-    <button class="btn btn-sm btn-info btn-rounded waves-effect waves-light pull-right row b-none addNewCategory" type="button"><span class="btn-label"><i class="fa fa-plus"></i></span><span lang="en">Add Category</span></button>
-    <div class="clearfix"></div>
+	<h1 lang="en">Add New Category</h1>
+	<fieldset style="border:0;">
+		<div class="form-group">
+			<label class="control-label" for="new-category-form-inputNameNew" lang="en">Category Name</label>
+			<input type="text" class="form-control" id="new-category-form-inputNameNew" name="category" required="" autofocus>
+		</div>
+		<div class="row">
+			<div class="form-group col-lg-6">
+				<label class="control-label" for="new-category-form-chooseImage" lang="en">Choose Image</label>
+				<select class="form-control categoryIconImageList" id="new-category-form-chooseImage" name="chooseImage"><option lang="en">Select or type Image</option></select>
+			</div>
+			<div class="form-group col-lg-6">
+				<label class="control-label" for="new-category-form-chooseIcon" lang="en">Choose Icon</label>
+				<select class="form-control categoryIconIconList" id="new-category-form-chooseIcon" name="chooseIcon"><option lang="en">Select or type Icon</option></select>
+			</div>
+		</div>
+		<div class="form-group">
+			<label class="control-label" for="new-category-form-inputImageNew" lang="en">Category Image</label>
+			<input type="text" class="form-control" id="new-category-form-inputImageNew" name="image"  required="">
+		</div>
+	</fieldset>
+	<button class="btn btn-sm btn-info btn-rounded waves-effect waves-light pull-right row b-none addNewCategory" type="button"><span class="btn-label"><i class="fa fa-plus"></i></span><span lang="en">Add Category</span></button>
+	<div class="clearfix"></div>
 </form>
 <form id="edit-category-form" class="mfp-hide white-popup-block mfp-with-anim">
-    <input type="hidden" name="id" value="">
-    <h1 lang="en">Edit Category</h1>
-    <fieldset style="border:0;">
-        <div class="form-group">
-            <label class="control-label" for="edit-category-form-inputName" lang="en">Category Name</label>
-            <input type="text" class="form-control" id="edit-category-form-inputName" name="category" required="" autofocus>
-        </div>
-        <div class="form-group">
-            <label class="control-label" for="edit-category-form-inputImage" lang="en">Category Image</label>
-            <div class="panel panel-info">
-                <div class="panel-heading">
-                    <span lang="en">Image Legend</span>
-                    <div class="pull-right"><a href="#" data-perform="panel-collapse"><i class="ti-plus"></i></a></div>
-                </div>
-                <div class="panel-wrapper collapse" aria-expanded="false">
-                    <div class="panel-body">
-                        <p lang="en">You may use an image or icon in this field</p>
-                        <p lang="en">For images, use the following format:</p><code>url::path/to/image</code>
-                        <p lang="en">For icons, use the following format:</p><code>icon-type::icon-name</code> i.e. <code>fontawesome::home</code>
-                    </div>
-                </div>
-            </div>
-            <input type="text" class="form-control" id="edit-category-form-inputImage" name="image"  required="">
-        </div>
-    </fieldset>
-    <button class="btn btn-sm btn-info btn-rounded waves-effect waves-light pull-right row b-none editCategory" type="button"><span class="btn-label"><i class="fa fa-plus"></i></span><span lang="en">Edit Category</span></button>
-    <div class="clearfix"></div>
+	<input type="hidden" name="id" value="">
+	<h1 lang="en">Edit Category</h1>
+	<fieldset style="border:0;">
+		<div class="form-group">
+			<label class="control-label" for="edit-category-form-inputName" lang="en">Category Name</label>
+			<input type="text" class="form-control" id="edit-category-form-inputName" name="category" required="" autofocus>
+		</div>
+		<div class="row">
+			<div class="form-group col-lg-6">
+				<label class="control-label" for="edit-category-form-chooseImage" lang="en">Choose Image</label>
+				<select class="form-control categoryIconImageList" id="edit-category-form-chooseImage" name="chooseImage"><option lang="en">Select or type Image</option></select>
+			</div>
+			<div class="form-group col-lg-6">
+				<label class="control-label" for="edit-category-form-chooseIcon" lang="en">Choose Icon</label>
+				<select class="form-control categoryIconIconList" id="edit-category-form-chooseIcon" name="chooseIcon"><option lang="en">Select or type Icon</option></select>
+			</div>
+		</div>
+		<div class="form-group">
+			<label class="control-label" for="edit-category-form-inputImage" lang="en">Category Image</label>
+			<input type="text" class="form-control" id="edit-category-form-inputImage" name="image"  required="">
+		</div>
+	</fieldset>
+	<button class="btn btn-sm btn-info btn-rounded waves-effect waves-light pull-right row b-none editCategory" type="button"><span class="btn-label"><i class="fa fa-plus"></i></span><span lang="en">Edit Category</span></button>
+	<div class="clearfix"></div>
 </form>
 ';
-}
+}

+ 3 - 2
api/pages/settings-tab-editor-homepage.php

@@ -18,11 +18,12 @@ function get_page_settings_tab_editor_homepage($Organizr)
 <div class="panel bg-org panel-info">
     <div class="panel-heading">
 		<span lang="en">Homepage Items</span>
-        <!-- <button type="button" class="btn btn-success btn-circle pull-right popup-with-form m-r-5" href="#new-image-form" data-effect="mfp-3d-unfold"><i class="fa fa-upload"></i> </button> -->
 	</div>
     <div class="panel-wrapper collapse in" aria-expanded="true">
         <div class="panel-body bg-org" >
-        <div class="row el-element-overlay m-b-40" id="settings-homepage-list"></div>
+        	<div class="row el-element-overlay m-b-40" id="settings-homepage-list">
+        		<div class="text-center"><i class="fa fa-spin fa-spinner fa-3x"></i></div>
+			</div>
         </div>
     </div>
 </div>

+ 217 - 169
api/pages/settings-tab-editor-tabs.php

@@ -11,195 +11,243 @@ function get_page_settings_tab_editor_tabs($Organizr)
 	if (!$Organizr->qualifyRequest(1, true)) {
 		return false;
 	}
-	$pageSettingsTabEditorTabsPerformanceIcon = $Organizr->config['performanceDisableIconDropdown'] ? '' : '
-	allIcons().success(function(data) {
-	    $(".tabIconIconList").select2({
-			data: data,
+	$iconSelectors = '
+		$(".tabIconIconList").select2({
+			ajax: {
+				url: \'api/v2/icon\',
+				data: function (params) {
+					var query = {
+						search: params.term,
+						page: params.page || 1
+					}
+					return query;
+				},
+				processResults: function (data, params) {
+					params.page = params.page || 1;
+					return {
+						results: data.response.data.results,
+						pagination: {
+							more: (params.page * 20) < data.response.data.total
+						}
+					};
+				},
+				//cache: true
+			},
+			placeholder: \'Search for an icon\',
 			templateResult: formatIcon,
-			templateSelection: formatIcon,
+			templateSelection: formatIcon
+		});
+		
+		$(".tabIconImageList").select2({
+			 ajax: {
+				url: \'api/v2/image/select\',
+				data: function (params) {
+					var query = {
+						search: params.term,
+						page: params.page || 1
+					}
+					return query;
+				},
+				processResults: function (data, params) {
+					params.page = params.page || 1;
+					return {
+						results: data.response.data.results,
+						pagination: {
+							more: (params.page * 20) < data.response.data.total
+						}
+					};
+				},
+				//cache: true
+			},
+			placeholder: \'Search for an image\',
+			templateResult: formatImage,
+			templateSelection: formatImage
 		});
-	});
-	$(".tabIconImageList").select2({
-		templateResult: formatImage,
-		templateSelection: formatImage,
-	});
-	';
-	$pageSettingsTabEditorTabsPerformanceImage = $Organizr->config['performanceDisableImageDropdown'] ? '' : '
-	$(".tabIconImageList").select2({
-		templateResult: formatImage,
-		templateSelection: formatImage,
-	});
 	';
 	return '
 	<script>
 	buildTabEditor();
 	!function(a){function f(a,b){if(!(a.originalEvent.touches.length>1)){a.preventDefault();var c=a.originalEvent.changedTouches[0],d=document.createEvent("MouseEvents");d.initMouseEvent(b,!0,!0,window,1,c.screenX,c.screenY,c.clientX,c.clientY,!1,!1,!1,!1,0,null),a.target.dispatchEvent(d)}}if(a.support.touch="ontouchend"in document,a.support.touch){var e,b=a.ui.mouse.prototype,c=b._mouseInit,d=b._mouseDestroy;b._touchStart=function(a){var b=this;!e&&b._mouseCapture(a.originalEvent.changedTouches[0])&&(e=!0,b._touchMoved=!1,f(a,"mouseover"),f(a,"mousemove"),f(a,"mousedown"))},b._touchMove=function(a){e&&(this._touchMoved=!0,f(a,"mousemove"))},b._touchEnd=function(a){e&&(f(a,"mouseup"),f(a,"mouseout"),this._touchMoved||f(a,"click"),e=!1)},b._mouseInit=function(){var b=this;b.element.bind({touchstart:a.proxy(b,"_touchStart"),touchmove:a.proxy(b,"_touchMove"),touchend:a.proxy(b,"_touchEnd")}),c.call(b)},b._mouseDestroy=function(){var b=this;b.element.unbind({touchstart:a.proxy(b,"_touchStart"),touchmove:a.proxy(b,"_touchMove"),touchend:a.proxy(b,"_touchEnd")}),d.call(b)}}}(jQuery);
 	$( \'#tabEditorTable\' ).sortable({
-	    stop: function () {
-	        $(\'input.order\').each(function(idx) {
-	            $(this).val(idx + 1);
-	        });
-	        var newTabs = $( "#submit-tabs-form" ).serializeToJSON();
-	        newTabsGlobal = newTabs;
-	        $(\'.saveTabOrderButton\').removeClass(\'hidden\');
-	        //submitTabOrder(newTabs);
-	    }
+		stop: function () {
+			$(\'input.order\').each(function(idx) {
+				$(this).val(idx + 1);
+			});
+			var newTabs = $( "#submit-tabs-form" ).serializeToJSON();
+			newTabsGlobal = newTabs;
+			$(\'.saveTabOrderButton\').removeClass(\'hidden\');
+			//submitTabOrder(newTabs);
+		}
 	});
 	$( \'#tabEditorTable\' ).disableSelection();
-	' . $pageSettingsTabEditorTabsPerformanceImage . $pageSettingsTabEditorTabsPerformanceIcon . '
+	' . $iconSelectors . '
 	</script>
 	<div class="panel bg-org panel-info">
-	    <div class="panel-heading">
-	        <span lang="en">Tab Editor</span>
-	        <button type="button" class="btn btn-info btn-circle pull-right popup-with-form m-r-5" href="#new-tab-form" data-effect="mfp-3d-unfold"><i class="fa fa-plus"></i> </button>
-	        <button type="button" class="btn btn-info btn-circle pull-right m-r-5 help-modal" data-modal="tabs"><i class="fa fa-question-circle"></i> </button>
-	    	<button onclick="submitTabOrder(newTabsGlobal)" class="btn btn-sm btn-info btn-rounded waves-effect waves-light pull-right animated loop-animation rubberBand m-r-20 saveTabOrderButton hidden" type="button"><span class="btn-label"><i class="fa fa-save"></i></span><span lang="en">Save Tab Order</span></button>
-	    </div>
-	    <div class="table-responsive">
-	        <form id="submit-tabs-form" onsubmit="return false;">
-	            <table class="table table-hover manage-u-table">
-	                <thead>
-	                    <tr>
-	                        <th width="70" class="text-center">#</th>
-	                        <th lang="en">NAME</th>
-	                        <th lang="en">CATEGORY</th>
-	                        <th lang="en">GROUP</th>
-	                        <th lang="en">TYPE</th>
-	                        <th lang="en" style="text-align:center">DEFAULT</th>
-	                        <th lang="en" style="text-align:center">ACTIVE</th>
-	                        <th lang="en" style="text-align:center">SPLASH</th>
-	                        <th lang="en" style="text-align:center">PING</th>
-	                        <th lang="en" style="text-align:center">PRELOAD</th>
-	                        <th lang="en" style="text-align:center">EDIT</th>
-	                        <th lang="en" style="text-align:center">DELETE</th>
-	                    </tr>
-	                </thead>
-	                <tbody id="tabEditorTable"></tbody>
-	            </table>
-	        </form>
-	    </div>
+		<div class="panel-heading">
+			<span lang="en">Tab Editor</span>
+			<button type="button" class="btn btn-info btn-circle pull-right popup-with-form m-r-5" href="#new-tab-form" data-effect="mfp-3d-unfold"><i class="fa fa-plus"></i> </button>
+			<button type="button" class="btn btn-info btn-circle pull-right m-r-5 help-modal" data-modal="tabs"><i class="fa fa-question-circle"></i> </button>
+			<button onclick="submitTabOrder(newTabsGlobal)" class="btn btn-sm btn-info btn-rounded waves-effect waves-light pull-right animated loop-animation rubberBand m-r-20 saveTabOrderButton hidden" type="button"><span class="btn-label"><i class="fa fa-save"></i></span><span lang="en">Save Tab Order</span></button>
+		</div>
+		<div class="table-responsive">
+			<form id="submit-tabs-form" onsubmit="return false;">
+				<table class="table table-hover manage-u-table">
+					<thead>
+						<tr>
+							<th width="70" class="text-center">#</th>
+							<th lang="en">NAME</th>
+							<th lang="en">CATEGORY</th>
+							<th lang="en">GROUP</th>
+							<th lang="en">TYPE</th>
+							<th lang="en" style="text-align:center">DEFAULT</th>
+							<th lang="en" style="text-align:center">ACTIVE</th>
+							<th lang="en" style="text-align:center">SPLASH</th>
+							<th lang="en" style="text-align:center">PING</th>
+							<th lang="en" style="text-align:center">PRELOAD</th>
+							<th lang="en" style="text-align:center">EDIT</th>
+							<th lang="en" style="text-align:center">DELETE</th>
+						</tr>
+					</thead>
+					<tbody id="tabEditorTable">
+						<td class="text-center" colspan="12"><i class="fa fa-spin fa-spinner"></i></td>
+					</tbody>
+				</table>
+			</form>
+		</div>
 	</div>
 	<form id="new-tab-form" class="mfp-hide white-popup-block mfp-with-anim">
-	    <h1 lang="en">Add New Tab</h1>
-	    <fieldset style="border:0;">
-	        <div class="alert alert-success alert-dismissable tabTestMessage hidden">
-	            <button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>
-	            <span lang="en">Tab can be set as iFrame</span>
-	        </div>
-	        <div class="alert alert-danger alert-dismissable tabTestMessage hidden">
-	            <button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>
-	            <span lang="en">Please set tab as [New Window] on next screen</span>
-	        </div>
-	        <div class="form-group">
-	            <label class="control-label" for="new-tab-form-inputNameNew" lang="en">Tab Name</label>
-	            <input type="text" class="form-control" id="new-tab-form-inputNameNew" name="name" required="" autofocus>
-	        </div>
-	        <div class="form-group">
-	            <label class="control-label" for="new-tab-form-inputURLNew" lang="en">Tab URL</label>
-	            <input type="text" class="form-control" id="new-tab-form-inputURLNew" name="url"  required="">
-	        </div>
-	        <div class="form-group">
-	            <label class="control-label" for="new-tab-form-inputURLLocalNew" lang="en">Tab Local URL</label>
-	            <input type="text" class="form-control" id="new-tab-form-inputURLLocalNew" name="url_local">
-	        </div>
-	        <div class="form-group">
-	            <label class="control-label" for="new-tab-form-inputPingURLNew" lang="en">Ping URL</label>
-	            <input type="text" class="form-control" id="new-tab-form-inputPingURLNew" name="ping_url"  placeholder="host/ip:port">
-	        </div>
-	        <div class="row">
-		        <div class="form-group col-lg-6">
-		            <label class="control-label" for="new-tab-form-inputTabActionTypeNew" lang="en">Tab Auto Action</label>
-		                <select class="form-control" id="new-tab-form-inputTabActionTypeNew" name="timeout">
-		                    <option value="null">None</option>
-		                    <option value="1">Auto Close</option>
-		                    <option value="2">Auto Reload</option>
+		<h1 lang="en">Add New Tab</h1>
+		<fieldset style="border:0;">
+			<div class="alert alert-success alert-dismissable tabTestMessage hidden">
+				<button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>
+				<span lang="en">Tab can be set as iFrame</span>
+			</div>
+			<div class="alert alert-danger alert-dismissable tabTestMessage hidden">
+				<button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>
+				<span lang="en">Please set tab as [New Window] on next screen</span>
+			</div>
+			<div class="form-group">
+				<label class="control-label" for="new-tab-form-inputNameNew" lang="en">Tab Name</label>
+				<input type="text" class="form-control" id="new-tab-form-inputNameNew" name="name" required="" autofocus>
+			</div>
+			<div class="form-group">
+				<label class="control-label" for="new-tab-form-inputURLNew" lang="en">Tab URL</label>
+				<input type="text" class="form-control" id="new-tab-form-inputURLNew" name="url"  required="">
+			</div>
+			<div class="form-group">
+				<label class="control-label" for="new-tab-form-inputURLLocalNew" lang="en">Tab Local URL</label>
+				<input type="text" class="form-control" id="new-tab-form-inputURLLocalNew" name="url_local">
+			</div>
+			<div class="form-group">
+				<label class="control-label" for="new-tab-form-inputPingURLNew" lang="en">Ping URL</label>
+				<input type="text" class="form-control" id="new-tab-form-inputPingURLNew" name="ping_url"  placeholder="host/ip:port">
+			</div>
+			<div class="row">
+				<div class="form-group col-lg-6">
+					<label class="control-label" for="new-tab-form-inputTabActionTypeNew" lang="en">Tab Auto Action</label>
+						<select class="form-control" id="new-tab-form-inputTabActionTypeNew" name="timeout">
+							<option value="null">None</option>
+							<option value="1">Auto Close</option>
+							<option value="2">Auto Reload</option>
 						</select>
-		        </div>
-		        <div class="form-group col-lg-6">
-		            <label class="control-label" for="new-tab-form-inputTabActionTimeNew" lang="en">Tab Auto Action Minutes</label>
-		                <input type="number" class="form-control" id="new-tab-form-inputTabActionTimeNew" name="timeout_ms"  placeholder="0">
-		        </div>
-		    </div>
-	        <div class="row">
-		        <div class="form-group col-lg-6">
-		            <label class="control-label" for="new-tab-form-chooseImage" lang="en">Choose Image</label>
-		            ' . $Organizr->imageSelect("new-tab-form") . '
-		        </div>
-		        <div class="form-group col-lg-6">
-		            <label class="control-label" for="new-tab-form-chooseIcon" lang="en">Choose Icon</label>
+				</div>
+				<div class="form-group col-lg-6">
+					<label class="control-label" for="new-tab-form-inputTabActionTimeNew" lang="en">Tab Auto Action Minutes</label>
+						<input type="number" class="form-control" id="new-tab-form-inputTabActionTimeNew" name="timeout_ms"  placeholder="0">
+				</div>
+			</div>
+			<div class="row">
+				<div class="form-group col-lg-4">
+					<label class="control-label" for="new-tab-form-chooseImage" lang="en">Choose Image</label>
+					<select class="form-control tabIconImageList" id="new-tab-form-chooseImage" name="chooseImage"><option lang="en">Select or type Image</option></select>
+				</div>
+				<div class="form-group col-lg-4">
+					<label class="control-label" for="new-tab-form-chooseIcon" lang="en">Choose Icon</label>
 					<select class="form-control tabIconIconList" id="new-tab-form-chooseIcon" name="chooseIcon"><option lang="en">Select or type Icon</option></select>
-		        </div>
-		    </div>
-	        <div class="form-group">
-	            <label class="control-label" for="new-tab-form-inputImageNew" lang="en">Tab Image</label>
-	            <input type="text" class="form-control" id="new-tab-form-inputImageNew" name="image"  required="">
-	        </div>
-	    </fieldset>
-	    <button class="btn btn-sm btn-info btn-rounded waves-effect waves-light row b-none testTab" type="button"><span class="btn-label"><i class="fa fa-flask"></i></span><span lang="en">Test Tab</span></button>
-	    <button class="btn btn-sm btn-info btn-rounded waves-effect waves-light pull-right row b-none addNewTab" type="button"><span class="btn-label"><i class="fa fa-plus"></i></span><span lang="en">Add Tab</span></button>
-	    <div class="clearfix"></div>
+				</div>
+				<div class="form-group col-lg-4">
+					<label class="control-label" for="new-tab-form-chooseBlackberry" lang="en">Choose Blackberry Theme Icon</label>
+					<button id="new-tab-form-chooseBlackberry" class="btn btn-xs btn-primary waves-effect waves-light form-control" onclick="showBlackberryThemes(\'new-tab-form-inputImageNew\');" type="button">
+						<i class="fa fa-search"></i>&nbsp; <span lang="en">Choose</span>
+					</button>
+				</div>
+			</div>
+			<div class="form-group">
+				<label class="control-label" for="new-tab-form-inputImageNew" lang="en">Tab Image</label>
+				<input type="text" class="form-control" id="new-tab-form-inputImageNew" name="image"  required="">
+			</div>
+		</fieldset>
+		<button class="btn btn-sm btn-info btn-rounded waves-effect waves-light row b-none testTab" type="button"><span class="btn-label"><i class="fa fa-flask"></i></span><span lang="en">Test Tab</span></button>
+		<button class="btn btn-sm btn-info btn-rounded waves-effect waves-light pull-right row b-none addNewTab" type="button"><span class="btn-label"><i class="fa fa-plus"></i></span><span lang="en">Add Tab</span></button>
+		<div class="clearfix"></div>
 	</form>
 	<form id="edit-tab-form" class="mfp-hide white-popup-block mfp-with-anim">
-	    <input type="hidden" name="id" value="x">
-	    <span class="hidden" id="originalTabName"></span>
-	    <h1 lang="en">Edit Tab</h1>
-	    <fieldset style="border:0;">
-	        <div class="alert alert-success alert-dismissable tabEditTestMessage hidden">
-	            <button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>
-	            <span lang="en">Tab can be set as iFrame</span>
-	        </div>
-	        <div class="alert alert-danger alert-dismissable tabEditTestMessage hidden">
-	            <button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>
-	            <span lang="en">Please set tab as [New Window] on next screen</span>
-	        </div>
-	        <div class="form-group">
-	            <label class="control-label" for="edit-tab-form-inputName" lang="en">Tab Name</label>
-	            <input type="text" class="form-control" id="edit-tab-form-inputName" name="name" required="" autofocus>
-	        </div>
-	        <div class="form-group">
-	            <label class="control-label" for="edit-tab-form-inputURL" lang="en">Tab URL</label>
-	            <input type="text" class="form-control" id="edit-tab-form-inputURL" name="url"  required="">
-	        </div>
-	        <div class="form-group">
-	            <label class="control-label" for="edit-tab-form-inputLocalURL" lang="en">Tab Local URL</label>
-	            <input type="text" class="form-control" id="edit-tab-form-inputLocalURL" name="url_local">
-	        </div>
-	        <div class="form-group">
-	            <label class="control-label" for="edit-tab-form-pingURL" lang="en">Ping URL</label>
-	            <input type="text" class="form-control" id="edit-tab-form-pingURL" name="ping_url" placeholder="host/ip:port">
-	        </div>
-	        <div class="row">
-		        <div class="form-group col-lg-6">
-		            <label class="control-label" for="edit-tab-form-inputTabActionTypeNew" lang="en">Tab Auto Action</label>
-		                <select class="form-control" id="edit-tab-form-inputTabActionTypeNew" name="timeout">
-		                    <option value="null">None</option>
-		                    <option value="1">Auto Close</option>
-		                    <option value="2">Auto Reload</option>
+		<input type="hidden" name="id" value="x">
+		<span class="hidden" id="originalTabName"></span>
+		<h1 lang="en">Edit Tab</h1>
+		<fieldset style="border:0;">
+			<div class="alert alert-success alert-dismissable tabEditTestMessage hidden">
+				<button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>
+				<span lang="en">Tab can be set as iFrame</span>
+			</div>
+			<div class="alert alert-danger alert-dismissable tabEditTestMessage hidden">
+				<button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>
+				<span lang="en">Please set tab as [New Window] on next screen</span>
+			</div>
+			<div class="form-group">
+				<label class="control-label" for="edit-tab-form-inputName" lang="en">Tab Name</label>
+				<input type="text" class="form-control" id="edit-tab-form-inputName" name="name" required="" autofocus>
+			</div>
+			<div class="form-group">
+				<label class="control-label" for="edit-tab-form-inputURL" lang="en">Tab URL</label>
+				<input type="text" class="form-control" id="edit-tab-form-inputURL" name="url"  required="">
+			</div>
+			<div class="form-group">
+				<label class="control-label" for="edit-tab-form-inputLocalURL" lang="en">Tab Local URL</label>
+				<input type="text" class="form-control" id="edit-tab-form-inputLocalURL" name="url_local">
+			</div>
+			<div class="form-group">
+				<label class="control-label" for="edit-tab-form-pingURL" lang="en">Ping URL</label>
+				<input type="text" class="form-control" id="edit-tab-form-pingURL" name="ping_url" placeholder="host/ip:port">
+			</div>
+			<div class="row">
+				<div class="form-group col-lg-6">
+					<label class="control-label" for="edit-tab-form-inputTabActionTypeNew" lang="en">Tab Auto Action</label>
+						<select class="form-control" id="edit-tab-form-inputTabActionTypeNew" name="timeout">
+							<option value="null">None</option>
+							<option value="1">Auto Close</option>
+							<option value="2">Auto Reload</option>
 						</select>
-		        </div>
-		        <div class="form-group col-lg-6">
-		            <label class="control-label" for="edit-tab-form-inputTabActionTimeNew" lang="en">Tab Auto Action Minutes</label>
-		                <input type="number" class="form-control" id="edit-tab-form-inputTabActionTimeNew" name="timeout_ms">
-		        </div>
-		    </div>
-	        <div class="row">
-		        <div class="form-group col-lg-6">
-		            <label class="control-label" for="edit-tab-form-chooseImage" lang="en">Choose Image</label>
-		            ' . $Organizr->imageSelect("edit-tab-form") . '
-		        </div>
-		        <div class="form-group col-lg-6">
-		            <label class="control-label" for="edit-tab-form-chooseIcon" lang="en">Choose Icon</label>
+				</div>
+				<div class="form-group col-lg-6">
+					<label class="control-label" for="edit-tab-form-inputTabActionTimeNew" lang="en">Tab Auto Action Minutes</label>
+						<input type="number" class="form-control" id="edit-tab-form-inputTabActionTimeNew" name="timeout_ms">
+				</div>
+			</div>
+			<div class="row">
+				<div class="form-group col-lg-4">
+					<label class="control-label" for="edit-tab-form-chooseImage" lang="en">Choose Image</label>
+					<select class="form-control tabIconImageList" id="edit-tab-form-chooseImage" name="chooseImage"><option lang="en">Select or type Image</option></select>
+				</div>
+				<div class="form-group col-lg-4">
+					<label class="control-label" for="edit-tab-form-chooseIcon" lang="en">Choose Icon</label>
 					<select class="form-control tabIconIconList" id="edit-tab-form-chooseIcon" name="chooseIcon"><option lang="en">Select or type Icon</option></select>
-		        </div>
-		    </div>
-	        <div class="form-group">
-	            <label class="control-label" for="edit-tab-form-inputImage" lang="en">Tab Image</label>
-	            <input type="text" class="form-control" id="edit-tab-form-inputImage" name="image"  required="">
-	        </div>
-	    </fieldset>
-	    <button class="btn btn-sm btn-info btn-rounded waves-effect waves-light row b-none testEditTab" type="button"><span class="btn-label"><i class="fa fa-flask"></i></span><span lang="en">Test Tab</span></button>
-	    <button class="btn btn-sm btn-info btn-rounded waves-effect waves-light pull-right row b-none editTab" type="button"><span class="btn-label"><i class="fa fa-check"></i></span><span lang="en">Edit Tab</span></button>
-	    <div class="clearfix"></div>
+				</div>
+				<div class="form-group col-lg-4">
+					<label class="control-label" for="edit-tab-form-chooseBlackberry" lang="en">Choose Blackberry Theme Icon</label>
+					<button id="edit-tab-form-chooseBlackberry" class="btn btn-xs btn-primary waves-effect waves-light form-control" onclick="showBlackberryThemes(\'edit-tab-form-inputImage\');" type="button">
+						<i class="fa fa-search"></i>&nbsp; <span lang="en">Choose</span>
+					</button>
+				</div>
+			</div>
+			<div class="form-group">
+				<label class="control-label" for="edit-tab-form-inputImage" lang="en">Tab Image</label>
+				<input type="text" class="form-control" id="edit-tab-form-inputImage" name="image"  required="">
+			</div>
+		</fieldset>
+		<button class="btn btn-sm btn-info btn-rounded waves-effect waves-light row b-none testEditTab" type="button"><span class="btn-label"><i class="fa fa-flask"></i></span><span lang="en">Test Tab</span></button>
+		<button class="btn btn-sm btn-info btn-rounded waves-effect waves-light pull-right row b-none editTab" type="button"><span class="btn-label"><i class="fa fa-check"></i></span><span lang="en">Edit Tab</span></button>
+		<div class="clearfix"></div>
 	</form>
 	';
 }

+ 7 - 2
api/pages/settings-user-manage-users.php

@@ -54,7 +54,6 @@ function get_page_settings_user_manage_users($Organizr)
         }
     });
 	$(function() {
-		
 		pageLength = 10;
 		function onPageSelect(newPageLength) {
             pageLength = newPageLength;
@@ -69,6 +68,7 @@ function get_page_settings_user_manage_users($Organizr)
 	 		loadIndication: true,
 		    loadIndicationDelay: 50000,
 		    loadMessage: "Please, wait...",
+		    loadShading: true,
 		    noDataContent: "Loading... or Not found",
 		    loadShading: true,
 	        filtering: false,
@@ -81,6 +81,11 @@ function get_page_settings_user_manage_users($Organizr)
 	        pageSize: pageLength,
 	        changePageSize: function (pageSize) {
                 var $this = this;
+                let totalUsers = $this.data.length;
+                let totalPages = Math.ceil(totalUsers / pageSize);
+                if($this.pageIndex > totalPages){
+                    $("#jsGrid-Users").jsGrid("openPage", totalPages);
+                }
                 $this.pageSize = pageLength;
                 $this.refresh();
             },
@@ -298,4 +303,4 @@ function get_page_settings_user_manage_users($Organizr)
     <div class="clearfix"></div>
 </form>
 ';
-}
+}

+ 36 - 13
api/pages/settings.php

@@ -12,7 +12,7 @@ function get_page_settings($Organizr)
 		return false;
 	}
 	$Organizr->writeLog('success', 'Admin Function -  Accessed Settings Page', $Organizr->user['username']);
-	return '
+	return $Organizr->pluginFiles('js', true) . '
 <script>
     (function() {
         updateCheck();
@@ -20,6 +20,7 @@ function get_page_settings($Organizr)
         sponsorLoad();
         newsLoad();
         checkCommitLoad();
+        backersLoad();
         [].slice.call(document.querySelectorAll(\'.sttabs-main-settings-div\')).forEach(function(el) {
             new CBPFWTabs(el);
         });
@@ -176,6 +177,8 @@ function get_page_settings($Organizr)
                             </li>
                             <li onclick="changeSettingsMenu(\'Settings::System Settings::Updates\')" role="presentation" class=""><a id="update-button" href="#settings-settings-updates" aria-controls="profile" role="tab" data-toggle="tab" aria-expanded="false"><span class="visible-xs"><i class="ti-package"></i></span> <span class="hidden-xs" lang="en">Updates</span></a>
                             </li>
+                            <li onclick="changeSettingsMenu(\'Settings::System Settings::Backup\');loadSettingsPage2(\'api/v2/page/settings_settings_backup\',\'#settings-settings-backup\',\'Backup\');" role="presentation" class=""><a id="settings-settings-backup-anchor" href="#settings-settings-backup" aria-controls="home" role="tab" data-toggle="tab" aria-expanded="false"><span class="visible-xs"><i class="ti-export"></i></span><span class="hidden-xs" lang="en">Backup</span></a>
+                            </li>
                             <li onclick="changeSettingsMenu(\'Settings::System Settings::Donate\')" role="presentation" class=""><a id="settings-settings-donate-anchor" href="#settings-settings-donate" aria-controls="profile" role="tab" data-toggle="tab" aria-expanded="false"><span class="visible-xs"><i class="ti-money"></i></span> <span class="hidden-xs" lang="en">Donate</span></a>
                             </li>
                         </ul>
@@ -193,6 +196,10 @@ function get_page_settings($Organizr)
                                 <h2 lang="en">Loading...</h2>
                                 <div class="clearfix"></div>
                             </div>
+                            <div role="tabpanel" class="tab-pane fade" id="settings-settings-backup">
+                                <h2 lang="en">Loading...</h2>
+                                <div class="clearfix"></div>
+                            </div>
                             <div role="tabpanel" class="tab-pane fade active in" id="settings-settings-about">
                             	<div class="row">
 	                                <div class="col-lg-12">
@@ -229,14 +236,16 @@ function get_page_settings($Organizr)
     											<li><a href="https://organizr.app/discord" target="_blank"><i class="mdi mdi-discord mdi-24px"></i></a></li>
     											<li><a href="https://github.com/causefx/organizr" target="_blank"><i class="mdi mdi-github-box mdi-24px"></i></a></li>
     										</ul>
+    										<hr>
+    										<a href="https://poeditor.com/join/project/T6l68hksTE" target="_blank">
+		                                        <div class="white-box bg-org">
+		                                            <h4 lang="en">Want to help translate?</h4>
+		                                            <p lang="en">Head on over to POEditor and help us translate Organizr into your language</p>
+		                                            <p lang="en">I will try and import new strings every Friday</p>
+		                                        </div>
+		                                    </a>
+    										
     									</div>
-    									<a href="https://poeditor.com/join/project/T6l68hksTE" target="_blank">
-	                                        <div class="white-box bg-org">
-	                                            <h4 lang="en">Want to help translate?</h4>
-	                                            <p lang="en">Head on over to POEditor and help us translate Organizr into your language</p>
-	                                            <p lang="en">I will try and import new strings every Friday</p>
-	                                        </div>
-	                                    </a>
     								</div>
                                     <div class="col-lg-6 col-sm-12 col-md-6">
                                         <div class="white-box bg-org">
@@ -269,6 +278,19 @@ function get_page_settings($Organizr)
 							            </div>
 							        </div>
     							</div>
+    							<div class="row">
+    							    <div class="col-lg-12">
+    							        <div class="panel panel-default">
+											<div class="panel-heading bg-org p-t-10 p-b-10">
+												<span class="pull-left m-t-5"><span lang="en">Backers</span></span>
+												<div class="clearfix"></div>
+											</div>
+							                <div class="panel-wrapper p-b-0 collapse in bg-org">
+							                	<div id="backersList" class="owl-carousel owl-theme backers-items"></div>
+							                </div>
+							            </div>
+                                    </div>
+    							</div>
                                 <div class="clearfix"></div>
                             </div>
                             <div role="tabpanel" class="tab-pane fade" id="settings-settings-donate">
@@ -276,7 +298,7 @@ function get_page_settings($Organizr)
                                     <div class="white-box bg-org">
                                         <ul class="nav nav-tabs tabs customtab">
                                             <li class="tab active">
-                                                <a href="#donate-beer" data-toggle="tab" aria-expanded="true"> <span class=""><i class="fa fa-beer text-warning"></i></span> <span class="hidden-xs" lang="en">Beerpay.io</span> </a>
+                                                <a href="#donate-github" data-toggle="tab" aria-expanded="true"> <span class=""><i class="fa fa-github text-warning"></i></span> <span class="hidden-xs" lang="en">Github Sponsor</span> </a>
                                             </li>
                                             <li class="tab">
                                                 <a href="#donate-paypal" data-toggle="tab" aria-expanded="true"> <span class=""><i class="fa fa-paypal text-info"></i></span> <span class="hidden-xs" lang="en">PayPal</span> </a>
@@ -298,9 +320,9 @@ function get_page_settings($Organizr)
                                             </li>
                                         </ul>
                                         <div class="tab-content">
-                                        	<div class="tab-pane active" id="donate-beer">
-                                                <blockquote>Want to show support on Beerpay.io?  Send me a beer :)<br/>Please click the button to continue.</blockquote>
-                                                <button onclick="window.open(\'https://beerpay.io/causefx/Organizr\', \'_blank\')" class="btn btn-primary btn-rounded waves-effect waves-light" type="button"><span class="btn-label"><i class="fa fa-link"></i></span><span lang="en">Continue To Website</span></button>
+                                        	<div class="tab-pane active" id="donate-github">
+                                                <blockquote>Want to show support on Github?  Sponsor me :)<br/>Please click the button to continue.</blockquote>
+                                                <button onclick="window.open(\'https://github.com/sponsors/causefx\', \'_blank\')" class="btn btn-primary btn-rounded waves-effect waves-light" type="button"><span class="btn-label"><i class="fa fa-link"></i></span><span lang="en">Continue To Website</span></button>
                                             </div>
                                             <div class="tab-pane" id="donate-paypal">
                                                 <blockquote>I have chosen to go with PayPal Pools so everyone can see how much people have donated.<br/>Please click the button to continue.</blockquote>
@@ -366,5 +388,6 @@ function get_page_settings($Organizr)
     <div class="clearfix"></div>
     <div id="about-theme-body" class=""></div>
 </form>
+<div id="editHomepageItemDiv"><div id="editHomepageItem" class=""></div></div>
 ';
-}
+}

+ 35 - 0
api/pages/tabs.php

@@ -0,0 +1,35 @@
+<?php
+$GLOBALS['organizrPages'][] = 'tabs';
+function get_page_tabs($Organizr)
+{
+	if (!$Organizr) {
+		$Organizr = new Organizr();
+	}
+	return '
+<script>
+</script>
+<div class="container-fluid">
+    <div class="row bg-title">
+        <div class="col-lg-3 col-md-4 col-sm-4 col-xs-12">
+            <h4 class="page-title" lang="en">No Tabs Available</h4>
+        </div>
+        <!-- /.col-lg-12 -->
+    </div>
+    <!--.row-->
+    <div class="row">
+        <div class="col-lg-12">
+            <div class="panel panel-warning">
+                <div class="panel-heading"> <i class="ti-alert fa-fw"></i> <span lang="en">No Tabs Available</span></div>
+                <div class="panel-wrapper collapse in" aria-expanded="true">
+                    <div class="panel-body">
+                        <p lang="en">There are no available tabs for your group - please contact the Administrator</p>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+    <!--./row-->
+</div>
+<!-- /.container-fluid -->
+';
+}

+ 6 - 7
api/pages/wizard.php

@@ -24,8 +24,8 @@ function get_page_wizard($Organizr)
                                     message: \'The username must be more than 2 and less than 30 characters long\'
                                 },
                                 regexp: {
-                                    regexp: /^[a-zA-Z0-9_\.]+$/,
-                                    message: \'The username can only consist of alphabetical, number, dot and underscore\'
+                                    regexp: /^[a-zA-Z0-9_\.\@]+$/,
+                                    message: \'The username can only consist of alphabetical, number, at sign, dot and underscore\'
                                 }
                             }
                         },
@@ -132,8 +132,7 @@ function get_page_wizard($Organizr)
             		var html = data.response;
                     location.reload();
             	}).fail(function(xhr) {
-            	    messageSingle(\'API Error\', xhr.responseJSON.response.message, activeInfo.settings.notifications.position, \'#FFF\', \'error\', \'10000\');
-            		console.error("Organizr Function: API Connection Failed | Error: " + xhr.responseJSON.response.message);
+            	    OrganizrApiError(xhr, \'API Error\');
             	});
             }
         });
@@ -246,8 +245,8 @@ function get_page_wizard($Organizr)
                                     </div>
                                     <div class="panel-wrapper collapse in" aria-expanded="true">
                                         <div class="panel-body">
-                                            <p lang="en">The Hash Key will be used to decrypt all passwords etc... on the server. {User-Generated]</p>
-                                            <p lang="en">The Registration Password will lockout the registration field with this password. {User-Generated]</p>
+                                            <p lang="en">The Hash Key will be used to decrypt all passwords etc... on the server. [User-Generated]</p>
+                                            <p lang="en">The Registration Password will lockout the registration field with this password. [User-Generated]</p>
                                             <p lang="en">The API Key will be used for all calls to organizr for the UI. [Auto-Generated]</p>
                                         </div>
                                     </div>
@@ -387,4 +386,4 @@ function get_page_wizard($Organizr)
 </div>
 <!-- /.container-fluid -->
 ';
-}
+}

+ 205 - 0
api/plugins/api/bookmark.php

@@ -0,0 +1,205 @@
+<?php
+$app->get('/plugins/bookmark/settings', function ($request, $response, $args) {
+	$Bookmark = new Bookmark();
+	if ($Bookmark->checkRoute($request)) {
+		if ($Bookmark->qualifyRequest(1, true)) {
+			$GLOBALS['api']['response']['data'] = $Bookmark->_getSettings();
+		}
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->get('/plugins/bookmark/page', function ($request, $response, $args) {
+	$Bookmark = new Bookmark();
+	if ($Bookmark->_checkRequest($request) && $Bookmark->checkRoute($request)) {
+		if ($Bookmark->qualifyRequest(1, true)) {
+			$GLOBALS['api']['response']['data'] = $Bookmark->_getPage();
+		}
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->get('/plugins/bookmark/setup/tab', function ($request, $response, $args) {
+	$Bookmark = new Bookmark();
+	if ($Bookmark->_checkRequest($request) && $Bookmark->checkRoute($request)) {
+		if ($Bookmark->qualifyRequest(1, true)) {
+			$GLOBALS['api']['response']['data'] = $Bookmark->_checkForBookmarkTab();
+		}
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->get('/plugins/bookmark/setup/category', function ($request, $response, $args) {
+	$Bookmark = new Bookmark();
+	if ($Bookmark->_checkRequest($request) && $Bookmark->checkRoute($request)) {
+		if ($Bookmark->qualifyRequest(1, true)) {
+			$GLOBALS['api']['response']['data'] = $Bookmark->_checkForBookmarkCategories();
+		}
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->get('/plugins/bookmark/settings_tab_editor_bookmark_tabs', function ($request, $response, $args) {
+	$Bookmark = new Bookmark();
+	if ($Bookmark->_checkRequest($request) && $Bookmark->checkRoute($request)) {
+		if ($Bookmark->qualifyRequest(1, true)) {
+			$GLOBALS['api']['response']['data'] = $Bookmark->_getSettingsTabEditorBookmarkTabsPage();
+		}
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->get('/plugins/bookmark/settings_tab_editor_bookmark_categories', function ($request, $response, $args) {
+	$Bookmark = new Bookmark();
+	if ($Bookmark->_checkRequest($request) && $Bookmark->checkRoute($request)) {
+		if ($Bookmark->qualifyRequest(1, true)) {
+			$GLOBALS['api']['response']['data'] = $Bookmark->_getSettingsTabEditorBookmarkCategoriesPage();
+		}
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
+// TABS
+$app->get('/plugins/bookmark/tabs', function ($request, $response, $args) {
+	$Bookmark = new Bookmark();
+	if ($Bookmark->_checkRequest($request) && $Bookmark->checkRoute($request)) {
+		if ($Bookmark->qualifyRequest(1, true)) {
+			$GLOBALS['api']['response']['data'] = $Bookmark->_getTabs();
+		}
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->get('/plugins/bookmark/tabs/{id}', function ($request, $response, $args) {
+	$Bookmark = new Bookmark();
+	if ($Bookmark->_checkRequest($request) && $Bookmark->checkRoute($request)) {
+		$GLOBALS['api']['response']['data'] = $Bookmark->_getTabByIdCheckUser($args['id']);
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->post('/plugins/bookmark/tabs', function ($request, $response, $args) {
+	$Bookmark = new Bookmark();
+	if ($Bookmark->_checkRequest($request) && $Bookmark->checkRoute($request)) {
+		if ($Bookmark->qualifyRequest(1, true)) {
+			$Bookmark->_addTab($Bookmark->apiData($request));
+		}
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->put('/plugins/bookmark/tabs', function ($request, $response, $args) {
+	$Bookmark = new Bookmark();
+	if ($Bookmark->_checkRequest($request) && $Bookmark->checkRoute($request)) {
+		if ($Bookmark->qualifyRequest(1, true)) {
+			$Bookmark->_updateTabOrder($Bookmark->apiData($request));
+		}
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->put('/plugins/bookmark/tabs/{id}', function ($request, $response, $args) {
+	$Bookmark = new Bookmark();
+	if ($Bookmark->_checkRequest($request) && $Bookmark->checkRoute($request)) {
+		if ($Bookmark->qualifyRequest(1, true)) {
+			$Bookmark->_updateTab($args['id'], $Bookmark->apiData($request));
+		}
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->delete('/plugins/bookmark/tabs/{id}', function ($request, $response, $args) {
+	$Bookmark = new Bookmark();
+	if ($Bookmark->_checkRequest($request) && $Bookmark->checkRoute($request)) {
+		if ($Bookmark->qualifyRequest(1, true)) {
+			$Bookmark->_deleteTab($args['id']);
+		}
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json')
+		->withStatus($GLOBALS['responseCode']);
+});
+// CATEGORIES
+$app->get('/plugins/bookmark/categories', function ($request, $response, $args) {
+	$Bookmark = new Bookmark();
+	if ($Bookmark->_checkRequest($request) && $Bookmark->checkRoute($request)) {
+		if ($Bookmark->qualifyRequest(1, true)) {
+			$GLOBALS['api']['response']['data'] = $Bookmark->_getTabs();
+		}
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->post('/plugins/bookmark/categories', function ($request, $response, $args) {
+	$Bookmark = new Bookmark();
+	if ($Bookmark->_checkRequest($request) && $Bookmark->checkRoute($request)) {
+		if ($Bookmark->qualifyRequest(1, true)) {
+			$Bookmark->_addCategory($Bookmark->apiData($request));
+		}
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->put('/plugins/bookmark/categories', function ($request, $response, $args) {
+	$Bookmark = new Bookmark();
+	if ($Bookmark->_checkRequest($request) && $Bookmark->checkRoute($request)) {
+		if ($Bookmark->qualifyRequest(1, true)) {
+			$Bookmark->_updateCategoryOrder($Bookmark->apiData($request));
+		}
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->put('/plugins/bookmark/categories/{id}', function ($request, $response, $args) {
+	$Bookmark = new Bookmark();
+	if ($Bookmark->_checkRequest($request) && $Bookmark->checkRoute($request)) {
+		if ($Bookmark->qualifyRequest(1, true)) {
+			$Bookmark->_updateCategory($args['id'], $Bookmark->apiData($request));
+		}
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->delete('/plugins/bookmark/categories/{id}', function ($request, $response, $args) {
+	$Bookmark = new Bookmark();
+	if ($Bookmark->_checkRequest($request) && $Bookmark->checkRoute($request)) {
+		if ($Bookmark->qualifyRequest(1, true)) {
+			$Bookmark->_deleteCategory($args['id']);
+		}
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json')
+		->withStatus($GLOBALS['responseCode']);
+});

+ 1079 - 0
api/plugins/bookmark.php

@@ -0,0 +1,1079 @@
+<?php
+// PLUGIN INFORMATION
+$GLOBALS['plugins'][]['Bookmark'] = array( // Plugin Name
+	'name' => 'Bookmark', // Plugin Name
+	'author' => 'leet1994', // Who wrote the plugin
+	'category' => 'Utilities', // One to Two Word Description
+	'link' => '', // Link to plugin info
+	'license' => 'personal,business', // License Type use , for multiple
+	'idPrefix' => 'BOOKMARK', // html element id prefix
+	'configPrefix' => 'BOOKMARK', // config file prefix for array items without the hypen
+	'dbPrefix' => 'BOOKMARK', // db prefix
+	'version' => '0.1.0', // SemVer of plugin
+	'image' => 'plugins/images/bookmark.png', // 1:1 non transparent image for plugin
+	'settings' => true, // does plugin need a settings modal?
+	'bind' => true, // use default bind to make settings page - true or false
+	'api' => 'api/v2/plugins/bookmark/settings', // api route for settings page
+	'homepage' => false // Is plugin for use on homepage? true or false
+);
+
+// Logo image under Public Domain from https://openclipart.org/detail/182527/open-book
+class Bookmark extends Organizr
+{
+	public function writeLog($type = 'error', $message = null, $username = null)
+	{
+		parent::writeLog($type, "Plugin 'Bookmark': " . $message, $username);
+	}
+	
+	public function _checkRequest($request)
+	{
+		$result = false;
+		if ($this->config['BOOKMARK-enabled'] && $this->hasDB()) {
+			if (!$this->_checkDatabaseTablesExist()) {
+				$this->_createDatabaseTables();
+			}
+			$result = true;
+		}
+		return $result;
+	}
+	
+	protected function _checkDatabaseTablesExist()
+	{
+		$response = [
+			array(
+				'function' => 'fetchSingle',
+				'query' => array(
+					"SELECT `name` FROM `sqlite_master` WHERE `type` = 'table' AND `name` = 'BOOKMARK-categories'"
+				),
+				'key' => 'BOOKMARK-categories'
+			),
+			array(
+				'function' => 'fetchSingle',
+				'query' => array(
+					"SELECT `name` FROM `sqlite_master` WHERE `type` = 'table' AND `name` = 'BOOKMARK-tabs'"
+				),
+				'key' => 'BOOKMARK-tabs'
+			),
+		];
+		$data = $this->processQueries($response);
+		return ($data["BOOKMARK-categories"] != false && $data["BOOKMARK-tabs"] != false);
+	}
+	
+	protected function _createDatabaseTables()
+	{
+		$response = [
+			array(
+				'function' => 'query',
+				'query' => 'CREATE TABLE `BOOKMARK-categories` (
+					`id`	INTEGER PRIMARY KEY AUTOINCREMENT UNIQUE,
+					`order`	INTEGER,
+					`category`	TEXT UNIQUE,
+					`category_id`	INTEGER,
+					`default` INTEGER
+				);'
+			),
+			array(
+				'function' => 'query',
+				'query' => 'CREATE TABLE `BOOKMARK-tabs` (
+					`id`	INTEGER PRIMARY KEY AUTOINCREMENT UNIQUE,
+					`order`	INTEGER,
+					`category_id`	INTEGER,
+					`name`	TEXT,
+					`url`	TEXT,
+					`enabled`	INTEGER,
+					`group_id`	INTEGER,
+					`image`	TEXT,
+					`background_color` TEXT,
+					`text_color` TEXT
+				);'
+			)
+		];
+		$this->processQueries($response);
+	}
+	
+	public function _getSettings()
+	{
+		return array(
+			'custom' => '
+				<div class="row">
+					<div class="col-lg-6 col-sm-12 col-md-6">
+						<div class="white-box">
+							<h3 class="box-title" lang="en">Automatic Setup Tasks</h3>
+							<ul class="feeds">
+								<li class="bookmark-check-tab">
+									<div class="bg-info">
+										<i class="sticon ti-layout-tab-v text-white"></i>
+									</div>
+									<small lang="en">Checking for Bookmark tab...</small>
+									<span class="text-muted result"><i class="fa fa-spin fa-refresh"></i></span>
+								</li>
+								<li class="bookmark-check-category">
+									<div class="bg-success">
+										<i class="ti-layout-list-thumb text-white"></i>
+									</div>
+									<small lang="en">Checking for bookmark default category...</small>
+									<span class="text-muted result"><i class="fa fa-spin fa-refresh"></i></span>
+								</li>
+							</ul>
+						</div>
+					</div>
+					<div class="col-lg-6 col-sm-12 col-md-6">
+						<div class="panel panel-info">
+							<div class="panel-heading">
+								<span lang="en">Notice</span>
+							</div>
+							<div class="panel-wrapper collapse in" aria-expanded="true">
+								<div class="panel-body">
+									<ul class="list-icons">
+										<li><i class="fa fa-chevron-right text-info"></i> Add tab that points to <i>api/v2/plugins/bookmark/page</i> and set it\'s type to <i>Organizr</i>.</li>
+										<li><i class="fa fa-chevron-right text-info"></i> Create Bookmark categories in the new area in <i>Tab Editor</i>.</li>
+										<li><i class="fa fa-chevron-right text-info"></i> Create Bookmark tabs in the new area in <i>Tab Editor</i>.</li>
+										<li><i class="fa fa-chevron-right text-info"></i> Open your custom Bookmark page via menu.</li>
+									</ul>
+								</div>
+							</div>
+						</div>
+					</div>
+				</div>
+			'
+		);
+	}
+	
+	public function _getPage()
+	{
+		$bookmarks = '<div id="BOOKMARK-wrapper">';
+		foreach ($this->_getAllCategories() as $category) {
+			$tabs = $this->_getRelevantTabsForCategory($category['category_id']);
+			if (count($tabs) == 0) continue;
+			$bookmarks .= '<div class="BOOKMARK-category">
+				<div class="BOOKMARK-category-title">
+					' . $category['category'] . '
+				</div>
+				<div class="BOOKMARK-category-content">';
+			foreach ($tabs as $tab) {
+				$bookmarks .= '<a href="' . $tab['url'] . '" target="_SELF">
+					<div class="BOOKMARK-tab"
+						style="border-color: ' . $this->adjustBrightness($tab['background_color'], 0.3) . '; background: linear-gradient(90deg, ' . $this->adjustBrightness($tab['background_color'], -0.3) . ' 0%, ' . $tab['background_color'] . ' 70%, ' . $this->adjustBrightness($tab['background_color'], 0.1) . ' 100%);">
+						<span class="BOOKMARK-tab-image">' . $this->_iconPrefix($tab['image']) . '</span>
+						<span class="BOOKMARK-tab-title" style="color: ' . $tab['text_color'] . ';">' . $tab['name'] . '</span>
+					</div>
+				</a>';
+			}
+			$bookmarks .= '</div></div>';
+		}
+		$bookmarks .= '</div>';
+		return $bookmarks;
+	}
+	
+	protected function _iconPrefix($source)
+	{
+		$tabIcon = explode("::", $source);
+		$icons = array(
+			"materialize" => "mdi mdi-",
+			"fontawesome" => "fa fa-",
+			"themify" => "ti-",
+			"simpleline" => "icon-",
+			"weathericon" => "wi wi-",
+			"alphanumeric" => "fa-fw",
+		);
+		if (is_array($tabIcon) && count($tabIcon) == 2) {
+			if ($tabIcon[0] !== 'url' && $tabIcon[0] !== 'alphanumeric') {
+				return '<i class="' . $icons[$tabIcon[0]] . $tabIcon[1] . '"></i>';
+			} else if ($tabIcon[0] == 'alphanumeric') {
+				return '<i>' . $tabIcon[1] . '</i>';
+			} else {
+				return '<img src="' . $tabIcon[1] . '" alt="tabIcon" />';
+			}
+		} else {
+			return '<img src="' . $source . '" alt="tabIcon" />';
+		}
+	}
+	
+	protected function _getAllCategories()
+	{
+		$response = [
+			array(
+				'function' => 'fetchAll',
+				'query' => 'SELECT * FROM `BOOKMARK-categories` ORDER BY `order` ASC'
+			)
+		];
+		return $this->processQueries($response);
+	}
+	
+	protected function _getRelevantTabsForCategory($category_id)
+	{
+		$response = [
+			array(
+				'function' => 'fetchAll',
+				'query' => array(
+					"SELECT * FROM `BOOKMARK-tabs` WHERE `enabled`='1' AND `category_id`=? AND `group_id`>=? ORDER BY `order` ASC",
+					$category_id,
+					$this->getUserLevel()
+				)
+			)
+		];
+		return $this->processQueries($response);
+	}
+	
+	public function _getTabs()
+	{
+		$response = [
+			array(
+				'function' => 'fetchAll',
+				'query' => 'SELECT * FROM `BOOKMARK-tabs` ORDER BY `order` ASC',
+				'key' => 'tabs'
+			),
+			array(
+				'function' => 'fetchAll',
+				'query' => 'SELECT * FROM `BOOKMARK-categories` ORDER BY `order` ASC',
+				'key' => 'categories'
+			),
+			array(
+				'function' => 'fetchAll',
+				'query' => 'SELECT * FROM `groups` ORDER BY `group_id` ASC',
+				'key' => 'groups'
+			)
+		];
+		return $this->processQueries($response);
+	}
+	
+	// Tabs
+	public function _getSettingsTabEditorBookmarkTabsPage()
+	{
+		$iconSelectors = '
+			$(".bookmarkTabIconIconList").select2({
+				ajax: {
+					url: \'api/v2/icon\',
+					data: function (params) {
+						var query = {
+							search: params.term,
+							page: params.page || 1
+						}
+						return query;
+					},
+					processResults: function (data, params) {
+						params.page = params.page || 1;
+						return {
+							results: data.response.data.results,
+							pagination: {
+								more: (params.page * 20) < data.response.data.total
+							}
+						};
+					},
+					//cache: true
+				},
+				placeholder: \'Search for an icon\',
+				templateResult: formatIcon,
+				templateSelection: formatIcon
+			});
+
+			$(".bookmarkTabIconImageList").select2({
+				 ajax: {
+					url: \'api/v2/image/select\',
+					data: function (params) {
+						var query = {
+							search: params.term,
+							page: params.page || 1
+						}
+						return query;
+					},
+					processResults: function (data, params) {
+						params.page = params.page || 1;
+						return {
+							results: data.response.data.results,
+							pagination: {
+								more: (params.page * 20) < data.response.data.total
+							}
+						};
+					},
+					//cache: true
+				},
+				placeholder: \'Search for an image\',
+				templateResult: formatImage,
+				templateSelection: formatImage
+			});
+		';
+		return '
+		<script>
+		buildBookmarkTabEditor();
+		!function(a){function f(a,b){if(!(a.originalEvent.touches.length>1)){a.preventDefault();var c=a.originalEvent.changedTouches[0],d=document.createEvent("MouseEvents");d.initMouseEvent(b,!0,!0,window,1,c.screenX,c.screenY,c.clientX,c.clientY,!1,!1,!1,!1,0,null),a.target.dispatchEvent(d)}}if(a.support.touch="ontouchend"in document,a.support.touch){var e,b=a.ui.mouse.prototype,c=b._mouseInit,d=b._mouseDestroy;b._touchStart=function(a){var b=this;!e&&b._mouseCapture(a.originalEvent.changedTouches[0])&&(e=!0,b._touchMoved=!1,f(a,"mouseover"),f(a,"mousemove"),f(a,"mousedown"))},b._touchMove=function(a){e&&(this._touchMoved=!0,f(a,"mousemove"))},b._touchEnd=function(a){e&&(f(a,"mouseup"),f(a,"mouseout"),this._touchMoved||f(a,"click"),e=!1)},b._mouseInit=function(){var b=this;b.element.bind({touchstart:a.proxy(b,"_touchStart"),touchmove:a.proxy(b,"_touchMove"),touchend:a.proxy(b,"_touchEnd")}),c.call(b)},b._mouseDestroy=function(){var b=this;b.element.unbind({touchstart:a.proxy(b,"_touchStart"),touchmove:a.proxy(b,"_touchMove"),touchend:a.proxy(b,"_touchEnd")}),d.call(b)}}}(jQuery);
+		$( \'#bookmarkTabEditorTable\' ).sortable({
+			stop: function () {
+				$(\'input.order\').each(function(idx) {
+					$(this).val(idx + 1);
+				});
+				var newTabs = $( "#submit-bookmark-tabs-form" ).serializeToJSON();
+				newBookmarkTabsGlobal = newTabs;
+				$(\'.saveBookmarkTabOrderButton\').removeClass(\'hidden\');
+				//submitTabOrder(newTabs);
+			}
+		});
+		$( \'#bookmarkTabEditorTable\' ).disableSelection();
+		' . $iconSelectors . '
+		</script>
+		<div class="panel bg-org panel-info">
+			<div class="panel-heading">
+				<span lang="en">Bookmark Tab Editor</span>
+				<button type="button" class="btn btn-info btn-circle pull-right popup-with-form m-r-5" href="#new-bookmark-tab-form" data-effect="mfp-3d-unfold"><i class="fa fa-plus"></i> </button>
+				<button onclick="submitBookmarkTabOrder(newBookmarkTabsGlobal)" class="btn btn-sm btn-info btn-rounded waves-effect waves-light pull-right animated loop-animation rubberBand m-r-20 saveBookmarkTabOrderButton hidden" type="button"><span class="btn-label"><i class="fa fa-save"></i></span><span lang="en">Save Tab Order</span></button>
+			</div>
+			<div class="table-responsive">
+				<form id="submit-bookmark-tabs-form" onsubmit="return false;">
+					<table class="table table-hover manage-u-table">
+						<thead>
+							<tr>
+								<th width="70" class="text-center">#</th>
+								<th lang="en">NAME</th>
+								<th lang="en">CATEGORY</th>
+								<th lang="en">GROUP</th>
+								<th lang="en" style="text-align:center">ACTIVE</th>
+								<th lang="en" style="text-align:center">EDIT</th>
+								<th lang="en" style="text-align:center">DELETE</th>
+							</tr>
+						</thead>
+						<tbody id="bookmarkTabEditorTable">
+							<td class="text-center" colspan="12"><i class="fa fa-spin fa-spinner"></i></td>
+						</tbody>
+					</table>
+				</form>
+			</div>
+		</div>
+		<form id="new-bookmark-tab-form" class="mfp-hide white-popup-block mfp-with-anim">
+			<h1 lang="en">Add New Tab</h1>
+			<fieldset style="border:0;">
+				<div class="form-group">
+					<label class="control-label" for="new-bookmark-tab-form-inputNameNew" lang="en">Tab Name</label>
+					<input type="text" class="form-control" id="new-bookmark-tab-form-inputNameNew" name="name" required="" autofocus>
+				</div>
+				<div class="form-group">
+					<label class="control-label" for="new-bookmark-tab-form-inputURLNew" lang="en">Tab URL</label>
+					<input type="text" class="form-control" id="new-bookmark-tab-form-inputURLNew" name="url"  required="">
+				</div>
+				<div class="row">
+					<div class="form-group col-lg-4">
+						<label class="control-label" for="new-bookmark-tab-form-chooseImage" lang="en">Choose Image</label>
+						<select class="form-control bookmarkTabIconImageList" id="new-bookmark-tab-form-chooseImage" name="chooseImage"><option lang="en">Select or type Image</option></select>
+					</div>
+					<div class="form-group col-lg-4">
+						<label class="control-label" for="new-bookmark-tab-form-chooseIcon" lang="en">Choose Icon</label>
+						<select class="form-control bookmarkTabIconIconList" id="new-bookmark-tab-form-chooseIcon" name="chooseIcon"><option lang="en">Select or type Icon</option></select>
+					</div>
+					<div class="form-group col-lg-4">
+						<label class="control-label" for="new-bookmark-tab-form-chooseBlackberry" lang="en">Choose Blackberry Theme Icon</label>
+						<button id="new-bookmark-tab-form-chooseBlackberry" class="btn btn-xs btn-primary waves-effect waves-light form-control" onclick="showBlackberryThemes(\'new-bookmark-tab-form-inputImageNew\');" type="button">
+							<i class="fa fa-search"></i>&nbsp; <span lang="en">Choose</span>
+						</button>
+					</div>
+				</div>
+				<div class="form-group">
+					<label class="control-label" for="new-bookmark-tab-form-inputImageNew" lang="en">Tab Image</label>
+					<input type="text" class="form-control" id="new-bookmark-tab-form-inputImageNew" name="image" required="">
+				</div>
+				<div class="row">
+					<div class="form-group col-lg-4">
+						<label class="control-label" for="new-bookmark-tab-form-inputBackgroundColorNew" lang="en">Background Color</label>
+						<input type="text" class="form-control" id="new-bookmark-tab-form-inputBackgroundColorNew" name="background_color" required="">
+					</div>
+					<div class="form-group col-lg-4">
+						<label class="control-label" for="new-bookmark-tab-form-inputTextColorNew" lang="en">Text Color</label>
+						<input type="text" class="form-control" id="new-bookmark-tab-form-inputTextColorNew" name="text_color" required="">
+					</div>
+				</div>
+			</fieldset>
+			<button class="btn btn-sm btn-info btn-rounded waves-effect waves-light pull-right row b-none addNewBookmarkTab" type="button"><span class="btn-label"><i class="fa fa-plus"></i></span><span lang="en">Add Tab</span></button>
+			<div class="clearfix"></div>
+		</form>
+		<form id="edit-bookmark-tab-form" class="mfp-hide white-popup-block mfp-with-anim">
+			<input type="hidden" name="id" value="x">
+			<span class="hidden" id="originalBookmarkTabName"></span>
+			<h1 lang="en">Edit Tab</h1>
+			<fieldset style="border:0;">
+				<div class="form-group">
+					<label class="control-label" for="edit-bookmark-tab-form-inputName" lang="en">Tab Name</label>
+					<input type="text" class="form-control" id="edit-bookmark-tab-form-inputName" name="name" required="" autofocus>
+				</div>
+				<div class="form-group">
+					<label class="control-label" for="edit-bookmark-tab-form-inputURL" lang="en">Tab URL</label>
+					<input type="text" class="form-control" id="edit-bookmark-tab-form-inputURL" name="url"  required="">
+				</div>
+				<div class="row">
+					<div class="form-group col-lg-4">
+						<label class="control-label" for="edit-bookmark-tab-form-chooseImage" lang="en">Choose Image</label>
+						<select class="form-control bookmarkTabIconImageList" id="edit-bookmark-tab-form-chooseImage" name="chooseImage"><option lang="en">Select or type Image</option></select>
+					</div>
+					<div class="form-group col-lg-4">
+						<label class="control-label" for="edit-bookmark-tab-form-chooseIcon" lang="en">Choose Icon</label>
+						<select class="form-control bookmarkTabIconIconList" id="edit-bookmark-tab-form-chooseIcon" name="chooseIcon"><option lang="en">Select or type Icon</option></select>
+					</div>
+					<div class="form-group col-lg-4">
+						<label class="control-label" for="edit-bookmark-tab-form-chooseBlackberry" lang="en">Choose Blackberry Theme Icon</label>
+						<button id="edit-bookmark-tab-form-chooseBlackberry" class="btn btn-xs btn-primary waves-effect waves-light form-control" onclick="showBlackberryThemes(\'edit-bookmark-tab-form-inputImage\');" type="button">
+							<i class="fa fa-search"></i>&nbsp; <span lang="en">Choose</span>
+						</button>
+					</div>
+				</div>
+				<div class="form-group">
+					<label class="control-label" for="edit-bookmark-tab-form-inputImage" lang="en">Tab Image</label>
+					<input type="text" class="form-control" id="edit-bookmark-tab-form-inputImage" name="image"  required="">
+				</div>
+				<div class="row">
+					<div class="form-group col-lg-4">
+						<label class="control-label" for="new-bookmark-tab-form-inputBackgroundColor" lang="en">Background Color</label>
+						<input type="text" class="form-control" id="new-bookmark-tab-form-inputBackgroundColor" name="background_color" required="">
+					</div>
+					<div class="form-group col-lg-4">
+						<label class="control-label" for="new-bookmark-tab-form-inputTextColor" lang="en">Text Color</label>
+						<input type="text" class="form-control" id="new-bookmark-tab-form-inputTextColor" name="text_color" required="">
+					</div>
+				</div>
+			</fieldset>
+			<button class="btn btn-sm btn-info btn-rounded waves-effect waves-light pull-right row b-none editBookmarkTab" type="button"><span class="btn-label"><i class="fa fa-check"></i></span><span lang="en">Edit Tab</span></button>
+			<div class="clearfix"></div>
+		</form>
+		';
+	}
+	
+	public function _isBookmarkTabNameTaken($name, $id = null)
+	{
+		if ($id) {
+			$response = [
+				array(
+					'function' => 'fetchAll',
+					'query' => array(
+						'SELECT * FROM `BOOKMARK-tabs` WHERE `name` LIKE ? AND `id` != ?',
+						$name,
+						$id
+					)
+				),
+			];
+		} else {
+			$response = [
+				array(
+					'function' => 'fetchAll',
+					'query' => array(
+						'SELECT * FROM `BOOKMARK-tabs` WHERE `name` LIKE ?',
+						$name
+					)
+				),
+			];
+		}
+		return $this->processQueries($response);
+	}
+	
+	public function _getNextBookmarkTabOrder()
+	{
+		$response = [
+			array(
+				'function' => 'fetchSingle',
+				'query' => array(
+					'SELECT `order` from `BOOKMARK-tabs` ORDER BY `order` DESC'
+				)
+			),
+		];
+		return $this->processQueries($response);
+	}
+	
+	public function _getBookmarkTabById($id)
+	{
+		$response = [
+			array(
+				'function' => 'fetch',
+				'query' => array(
+					'SELECT * FROM `BOOKMARK-tabs` WHERE `id` = ?',
+					$id
+				)
+			),
+		];
+		return $this->processQueries($response);
+	}
+	
+	public function _getTabByIdCheckUser($id)
+	{
+		$tabInfo = $this->_getBookmarkTabById($id);
+		if ($tabInfo) {
+			if ($this->qualifyRequest($tabInfo['group_id'], true)) {
+				return $tabInfo;
+			}
+		} else {
+			$this->setAPIResponse('error', 'id not found', 404);
+			return false;
+		}
+	}
+	
+	public function _deleteTab($id)
+	{
+		$response = [
+			array(
+				'function' => 'query',
+				'query' => array(
+					'DELETE FROM `BOOKMARK-tabs` WHERE id = ?',
+					$id
+				)
+			),
+		];
+		$tabInfo = $this->_getBookmarkTabById($id);
+		if ($tabInfo) {
+			$this->writeLog('success', 'Tab Delete Function -  Deleted Tab [' . $tabInfo['name'] . ']', $this->user['username']);
+			$this->setAPIResponse('success', 'Tab deleted', 204);
+			return $this->processQueries($response);
+		} else {
+			$this->setAPIResponse('error', 'id not found', 404);
+			return false;
+		}
+	}
+	
+	public function _addTab($array)
+	{
+		if (!$array) {
+			$this->setAPIResponse('error', 'no data was sent', 422);
+			return null;
+		}
+		$array = $this->checkKeys($this->getTableColumnsFormatted('BOOKMARK-tabs'), $array);
+		$array['group_id'] = ($array['group_id']) ?? $this->getDefaultGroupId();
+		$array['category_id'] = ($array['category_id']) ?? $this->_getDefaultBookmarkCategoryId();
+		$array['enabled'] = ($array['enabled']) ?? 0;
+		$array['order'] = ($array['order']) ?? $this->_getNextBookmarkTabOrder() + 1;
+		if (array_key_exists('name', $array)) {
+			if ($this->_isBookmarkTabNameTaken($array['name'])) {
+				$this->setAPIResponse('error', 'Tab name: ' . $array['name'] . ' is already taken', 409);
+				return false;
+			}
+		} else {
+			$this->setAPIResponse('error', 'Tab name was not supplied', 422);
+			return false;
+		}
+		if (!array_key_exists('url', $array)) {
+			$this->setAPIResponse('error', 'Tab url was not supplied', 422);
+			return false;
+		}
+		if (!array_key_exists('image', $array)) {
+			$this->setAPIResponse('error', 'Tab image was not supplied', 422);
+			return false;
+		}
+		if (array_key_exists('background_color', $array)) {
+			if (!$this->_checkColorHexCode($array['background_color'])) {
+				$this->setAPIResponse('error', 'Tab background color is invalid', 422);
+				return false;
+			}
+		} else {
+			$this->setAPIResponse('error', 'Tab background color was not supplied', 422);
+			return false;
+		}
+		if (array_key_exists('text_color', $array)) {
+			if (!$this->_checkColorHexCode($array['text_color'])) {
+				$this->setAPIResponse('error', 'Tab text color is invalid', 422);
+				return false;
+			}
+		} else {
+			$this->setAPIResponse('error', 'Tab text color was not supplied', 422);
+			return false;
+		}
+		$response = [
+			array(
+				'function' => 'query',
+				'query' => array(
+					'INSERT INTO [BOOKMARK-tabs]',
+					$array
+				)
+			),
+		];
+		$this->setAPIResponse(null, 'Tab added');
+		$this->writeLog('success', 'Tab Editor Function -  Added Tab for [' . $array['name'] . ']', $this->user['username']);
+		return $this->processQueries($response);
+	}
+	
+	public function _updateTab($id, $array)
+	{
+		if (!$id || $id == '') {
+			$this->setAPIResponse('error', 'id was not set', 422);
+			return null;
+		}
+		if (!$array) {
+			$this->setAPIResponse('error', 'no data was sent', 422);
+			return null;
+		}
+		$tabInfo = $this->_getBookmarkTabById($id);
+		if ($tabInfo) {
+			$array = $this->checkKeys($tabInfo, $array);
+		} else {
+			$this->setAPIResponse('error', 'No tab info found', 404);
+			return false;
+		}
+		if (array_key_exists('name', $array)) {
+			if ($this->_isBookmarkTabNameTaken($array['name'], $id)) {
+				$this->setAPIResponse('error', 'Tab name: ' . $array['name'] . ' is already taken', 409);
+				return false;
+			}
+		}
+		if (array_key_exists('background_color', $array)) {
+			if (!$this->_checkColorHexCode($array['background_color'])) {
+				$this->setAPIResponse('error', 'Tab background color is invalid', 422);
+				return false;
+			}
+		}
+		if (array_key_exists('text_color', $array)) {
+			if (!$this->_checkColorHexCode($array['text_color'])) {
+				$this->setAPIResponse('error', 'Tab text color is invalid', 422);
+				return false;
+			}
+		}
+		$response = [
+			array(
+				'function' => 'query',
+				'query' => array(
+					'UPDATE `BOOKMARK-tabs` SET',
+					$array,
+					'WHERE id = ?',
+					$id
+				)
+			),
+		];
+		$this->setAPIResponse(null, 'Tab info updated');
+		$this->writeLog('success', 'Tab Editor Function -  Edited Tab Info for [' . $tabInfo['name'] . ']', $this->user['username']);
+		return $this->processQueries($response);
+	}
+	
+	public function _updateTabOrder($array)
+	{
+		if (count($array) >= 1) {
+			foreach ($array as $tab) {
+				if (count($tab) !== 2) {
+					$this->setAPIResponse('error', 'data is malformed', 422);
+					break;
+				}
+				$id = $tab['id'] ?? null;
+				$order = $tab['order'] ?? null;
+				if ($id && $order) {
+					$response = [
+						array(
+							'function' => 'query',
+							'query' => array(
+								'UPDATE `BOOKMARK-tabs` set `order` = ? WHERE `id` = ?',
+								$order,
+								$id
+							)
+						),
+					];
+					$this->processQueries($response);
+					$this->setAPIResponse(null, 'Tab Order updated');
+				} else {
+					$this->setAPIResponse('error', 'data is malformed', 422);
+				}
+			}
+		} else {
+			$this->setAPIResponse('error', 'data is empty or not in array', 422);
+			return false;
+		}
+	}
+	
+	// Categories
+	public function _getSettingsTabEditorBookmarkCategoriesPage()
+	{
+		return '
+	<script>
+	buildBookmarkCategoryEditor();
+	$( \'#bookmarkCategoryEditorTable\' ).sortable({
+		stop: function () {
+			var inputs = $(\'input.order\');
+			var nbElems = inputs.length;
+			inputs.each(function(idx) {
+				$(this).val(idx + 1);
+			});
+			submitBookmarkCategoryOrder();
+		}
+	});
+	</script>
+	<div class="panel bg-org panel-info">
+		<div class="panel-heading">
+			<span lang="en">Bookmark Category Editor</span>
+			<button type="button" class="btn btn-success btn-circle pull-right popup-with-form m-r-5" href="#new-bookmark-category-form" data-effect="mfp-3d-unfold"><i class="fa fa-plus"></i> </button>
+		</div>
+		<div class="table-responsive">
+			<form id="submit-bookmark-categories-form" onsubmit="return false;">
+				<table class="table table-hover manage-u-table">
+					<thead>
+						<tr>
+							<th lang="en">NAME</th>
+							<th lang="en" style="text-align:center">TABS</th>
+							<th lang="en" style="text-align:center">DEFAULT</th>
+							<th lang="en" style="text-align:center">EDIT</th>
+							<th lang="en" style="text-align:center">DELETE</th>
+						</tr>
+					</thead>
+					<tbody id="bookmarkCategoryEditorTable"><td class="text-center" colspan="6"><i class="fa fa-spin fa-spinner"></i></td></tbody>
+				</table>
+			</form>
+		</div>
+	</div>
+	<form id="new-bookmark-category-form" class="mfp-hide white-popup-block mfp-with-anim">
+		<h1 lang="en">Add New Bookmark Category</h1>
+		<fieldset style="border:0;">
+			<div class="form-group">
+				<label class="control-label" for="new-bookmark-category-form-inputNameNew" lang="en">Category Name</label>
+				<input type="text" class="form-control" id="new-bookmark-category-form-inputNameNew" name="category" required="" autofocus>
+			</div>
+		</fieldset>
+		<button class="btn btn-sm btn-info btn-rounded waves-effect waves-light pull-right row b-none addNewBookmarkCategory" type="button"><span class="btn-label"><i class="fa fa-plus"></i></span><span lang="en">Add Category</span></button>
+		<div class="clearfix"></div>
+	</form>
+	<form id="edit-bookmark-category-form" class="mfp-hide white-popup-block mfp-with-anim">
+		<input type="hidden" name="id" value="">
+		<h1 lang="en">Edit Category</h1>
+		<fieldset style="border:0;">
+			<div class="form-group">
+				<label class="control-label" for="edit-bookmark-category-form-inputName" lang="en">Category Name</label>
+				<input type="text" class="form-control" id="edit-bookmark-category-form-inputName" name="category" required="" autofocus>
+			</div>
+		</fieldset>
+		<button class="btn btn-sm btn-info btn-rounded waves-effect waves-light pull-right row b-none editBookmarkCategory" type="button"><span class="btn-label"><i class="fa fa-plus"></i></span><span lang="en">Edit Category</span></button>
+		<div class="clearfix"></div>
+	</form>
+	';
+	}
+	
+	public function _getDefaultBookmarkCategoryId()
+	{
+		$response = [
+			array(
+				'function' => 'fetchSingle',
+				'query' => array(
+					'SELECT `category_id` FROM `BOOKMARK-categories` WHERE `default` = 1'
+				)
+			),
+		];
+		return $this->processQueries($response);
+	}
+	
+	public function _getNextBookmarkCategoryOrder()
+	{
+		$response = [
+			array(
+				'function' => 'fetchSingle',
+				'query' => array(
+					'SELECT `order` from `BOOKMARK-categories` ORDER BY `order` DESC'
+				)
+			),
+		];
+		return $this->processQueries($response);
+	}
+	
+	public function _getNextBookmarkCategoryId()
+	{
+		$response = [
+			array(
+				'function' => 'fetchSingle',
+				'query' => array(
+					'SELECT `category_id` from `BOOKMARK-categories` ORDER BY `category_id` DESC'
+				)
+			),
+		];
+		return $this->processQueries($response);
+	}
+	
+	public function _isBookmarkCategoryNameTaken($name, $id = null)
+	{
+		if ($id) {
+			$response = [
+				array(
+					'function' => 'fetchAll',
+					'query' => array(
+						'SELECT * FROM `BOOKMARK-categories` WHERE `category` LIKE ? AND `id` != ?',
+						$name,
+						$id
+					)
+				),
+			];
+		} else {
+			$response = [
+				array(
+					'function' => 'fetchAll',
+					'query' => array(
+						'SELECT * FROM `BOOKMARK-categories` WHERE `category` LIKE ?',
+						$name
+					)
+				),
+			];
+		}
+		return $this->processQueries($response);
+	}
+	
+	public function _getBookmarkCategoryById($id)
+	{
+		$response = [
+			array(
+				'function' => 'fetch',
+				'query' => array(
+					'SELECT * FROM `BOOKMARK-categories` WHERE `id` = ?',
+					$id
+				)
+			),
+		];
+		return $this->processQueries($response);
+	}
+	
+	public function _clearBookmarkCategoryDefault()
+	{
+		$response = [
+			array(
+				'function' => 'query',
+				'query' => array(
+					'UPDATE `BOOKMARK-categories` SET `default` = 0'
+				)
+			),
+		];
+		return $this->processQueries($response);
+	}
+	
+	public function _addCategory($array)
+	{
+		if (!$array) {
+			$this->setAPIResponse('error', 'no data was sent', 422);
+			return null;
+		}
+		$array = $this->checkKeys($this->getTableColumnsFormatted('BOOKMARK-categories'), $array);
+		$array['default'] = ($array['default']) ?? 0;
+		$array['order'] = ($array['order']) ?? $this->_getNextBookmarkCategoryOrder() + 1;
+		$array['category_id'] = ($array['category_id']) ?? $this->_getNextBookmarkCategoryId() + 1;
+		if (array_key_exists('category', $array)) {
+			if ($this->_isBookmarkCategoryNameTaken($array['category'])) {
+				$this->setAPIResponse('error', 'Category name: ' . $array['category'] . ' is already taken', 409);
+				return false;
+			}
+		} else {
+			$this->setAPIResponse('error', 'Category name was not supplied', 422);
+			return false;
+		}
+		$response = [
+			array(
+				'function' => 'query',
+				'query' => array(
+					'INSERT INTO [BOOKMARK-categories]',
+					$array
+				)
+			),
+		];
+		$this->setAPIResponse(null, 'Category added');
+		$this->writeLog('success', 'Category Editor Function -  Added Category for [' . $array['category'] . ']', $this->user['username']);
+		$result = $this->processQueries($response);
+		$this->_correctDefaultCategory();
+		return $result;
+	}
+	
+	public function _updateCategory($id, $array)
+	{
+		if (!$id || $id == '') {
+			$this->setAPIResponse('error', 'id was not set', 422);
+			return null;
+		}
+		if (!$array) {
+			$this->setAPIResponse('error', 'no data was sent', 422);
+			return null;
+		}
+		$categoryInfo = $this->_getBookmarkCategoryById($id);
+		if ($categoryInfo) {
+			$array = $this->checkKeys($categoryInfo, $array);
+		} else {
+			$this->setAPIResponse('error', 'No category info found', 404);
+			return false;
+		}
+		if (array_key_exists('category', $array)) {
+			if ($this->_isBookmarkCategoryNameTaken($array['category'], $id)) {
+				$this->setAPIResponse('error', 'Category name: ' . $array['category'] . ' is already taken', 409);
+				return false;
+			}
+		}
+		if (array_key_exists('default', $array)) {
+			if ($array['default']) {
+				$this->_clearBookmarkCategoryDefault();
+			}
+		}
+		$response = [
+			array(
+				'function' => 'query',
+				'query' => array(
+					'UPDATE `BOOKMARK-categories` SET',
+					$array,
+					'WHERE id = ?',
+					$id
+				)
+			),
+		];
+		$this->setAPIResponse(null, 'Category info updated');
+		$this->writeLog('success', 'Category Editor Function -  Edited Category Info for [' . $categoryInfo['category'] . ']', $this->user['username']);
+		$result = $this->processQueries($response);
+		$this->_correctDefaultCategory();
+		return $result;
+	}
+	
+	public function _updateCategoryOrder($array)
+	{
+		if (count($array) >= 1) {
+			foreach ($array as $category) {
+				if (count($category) !== 2) {
+					$this->setAPIResponse('error', 'data is malformed', 422);
+					break;
+				}
+				$id = $category['id'] ?? null;
+				$order = $category['order'] ?? null;
+				if ($id && $order) {
+					$response = [
+						array(
+							'function' => 'query',
+							'query' => array(
+								'UPDATE `BOOKMARK-categories` set `order` = ? WHERE `id` = ?',
+								$order,
+								$id
+							)
+						),
+					];
+					$this->processQueries($response);
+					$this->setAPIResponse(null, 'Category Order updated');
+				} else {
+					$this->setAPIResponse('error', 'data is malformed', 422);
+				}
+			}
+		} else {
+			$this->setAPIResponse('error', 'data is empty or not in array', 422);
+			return false;
+		}
+	}
+	
+	public function _deleteCategory($id)
+	{
+		$response = [
+			array(
+				'function' => 'query',
+				'query' => array(
+					'DELETE FROM `BOOKMARK-categories` WHERE id = ?',
+					$id
+				)
+			),
+		];
+		$categoryInfo = $this->_getBookmarkCategoryById($id);
+		if ($categoryInfo) {
+			$this->writeLog('success', 'Category Delete Function -  Deleted Category [' . $categoryInfo['category'] . ']', $this->user['username']);
+			$this->setAPIResponse('success', 'Category deleted', 204);
+			$result = $this->processQueries($response);
+			$this->_correctDefaultCategory();
+			return $result;
+		} else {
+			$this->setAPIResponse('error', 'id not found', 404);
+			return false;
+		}
+	}
+	
+	protected function _correctDefaultCategory()
+	{
+		if ($this->_getDefaultBookmarkCategoryId() == null) {
+			$response = [
+				array(
+					'function' => 'query',
+					'query' => 'UPDATE `BOOKMARK-categories` SET `default` = 1 WHERE `category_id` = (SELECT `category_id` FROM `BOOKMARK-categories` ORDER BY `category_id` ASC LIMIT 0,1)'
+				)
+			];
+			return $this->processQueries($response);
+		}
+	}
+	
+	protected function _checkColorHexCode($hex)
+	{
+		return preg_match('/^\#([0-9a-fA-F]{3}){1,2}$/', $hex);
+	}
+	
+	/**
+	 * Increases or decreases the brightness of a color by a percentage of the current brightness.
+	 *
+	 * @param string $hexCode       Supported formats: `#FFF`, `#FFFFFF`, `FFF`, `FFFFFF`
+	 * @param float  $adjustPercent A number between -1 and 1. E.g. 0.3 = 30% lighter; -0.4 = 40% darker.
+	 *
+	 * @return  string
+	 *
+	 * @author  maliayas
+	 * @link    https://stackoverflow.com/questions/3512311/how-to-generate-lighter-darker-color-with-php
+	 */
+	protected function adjustBrightness($hexCode, $adjustPercent)
+	{
+		$hexCode = ltrim($hexCode, '#');
+		if (strlen($hexCode) == 3) {
+			$hexCode = $hexCode[0] . $hexCode[0] . $hexCode[1] . $hexCode[1] . $hexCode[2] . $hexCode[2];
+		}
+		$hexCode = array_map('hexdec', str_split($hexCode, 2));
+		foreach ($hexCode as &$color) {
+			$adjustableLimit = $adjustPercent < 0 ? $color : 255 - $color;
+			$adjustAmount = ceil($adjustableLimit * $adjustPercent);
+			$color = str_pad(dechex($color + $adjustAmount), 2, '0', STR_PAD_LEFT);
+		}
+		return '#' . implode($hexCode);
+	}
+	
+	public function _checkForBookmarkTab()
+	{
+		$response = [
+			array(
+				'function' => 'fetchAll',
+				'query' => array(
+					'SELECT * FROM tabs',
+					'WHERE url = ?',
+					'api/v2/plugins/bookmark/page'
+				)
+			),
+		];
+		$tab = $this->processQueries($response);
+		if ($tab) {
+			$this->setAPIResponse('success', 'Tab already exists', 200);
+			return $tab;
+		} else {
+			$createTab = $this->_createBookmarkTab();
+			if ($createTab) {
+				$tab = $this->processQueries($response);
+				$this->setAPIResponse('success', 'Tab created', 200);
+				return $tab;
+			} else {
+				$this->setAPIResponse('error', 'Tab creation error', 500);
+			}
+		}
+	}
+	
+	public function _createBookmarkTab()
+	{
+		$tabInfo = [
+			'order' => $this->getNextTabOrder() + 1,
+			'category_id' => $this->getDefaultCategoryId(),
+			'name' => 'Bookmarks',
+			'url' => 'api/v2/plugins/bookmark/page',
+			'default' => false,
+			'enabled' => true,
+			'group_id' => $this->getDefaultGroupId(),
+			'image' => 'fontawesome::book',
+			'type' => 0
+		];
+		$response = [
+			array(
+				'function' => 'query',
+				'query' => array(
+					'INSERT INTO [tabs]',
+					$tabInfo
+				)
+			),
+		];
+		return $this->processQueries($response);
+	}
+	
+	public function _checkForBookmarkCategories()
+	{
+		$categories = $this->_getAllCategories();
+		if ($categories) {
+			$this->setAPIResponse('success', 'Categories already exists', 200);
+			return $categories;
+		} else {
+			$createCategory = $this->_addCategory(['category' => 'Unsorted', 'default' => 1]);
+			if ($createCategory) {
+				$categories = $this->_getAllCategories();
+				$this->setAPIResponse('success', 'Category created', 200);
+				return $categories;
+			} else {
+				$this->setAPIResponse('error', 'Category creation error', 500);
+			}
+		}
+	}
+}

+ 20 - 12
api/plugins/chat.php

@@ -1,5 +1,7 @@
 <?php
 // PLUGIN INFORMATION
+use Pusher\PusherException;
+
 $GLOBALS['plugins'][]['chat'] = array( // Plugin Name
 	'name' => 'Chat', // Plugin Name
 	'author' => 'CauseFX', // Who wrote the plugin
@@ -10,7 +12,9 @@ $GLOBALS['plugins'][]['chat'] = array( // Plugin Name
 	'configPrefix' => 'CHAT', // config file prefix for array items without the hypen
 	'version' => '1.0.0', // SemVer of plugin
 	'image' => 'plugins/images/chat.png', // 1:1 non transparent image for plugin
-	'settings' => true, // does plugin need a settings page? true or false
+	'settings' => true, // does plugin need a settings modal?
+	'bind' => true, // use default bind to make settings page - true or false
+	'api' => 'api/v2/plugins/chat/settings', // api route for settings page
 	'homepage' => false // Is plugin for use on homepage? true or false
 );
 
@@ -138,18 +142,22 @@ class Chat extends Organizr
 		$query = $this->processQueries($response);
 		if ($query) {
 			$options = array(
-				'cluster' => $GLOBALS['CHAT-cluster-include'],
-				'useTLS' => $GLOBALS['CHAT-useSSL']
-			);
-			$pusher = new Pusher\Pusher(
-				$GLOBALS['CHAT-authKey-include'],
-				$GLOBALS['CHAT-secret'],
-				$GLOBALS['CHAT-appID-include'],
-				$options
+				'cluster' => $this->config['CHAT-cluster-include'],
+				'useTLS' => $this->config['CHAT-useSSL']
 			);
-			$pusher->trigger('org_channel', 'my-event', $newMessage);
-			$this->setAPIResponse('success', 'Chat message accepted', 200);
-			return true;
+			try {
+				$pusher = new Pusher\Pusher(
+					$this->config['CHAT-authKey-include'],
+					$this->config['CHAT-secret'],
+					$this->config['CHAT-appID-include'],
+					$options
+				);
+				$pusher->trigger('org_channel', 'my-event', $newMessage);
+				$this->setAPIResponse('success', 'Chat message accepted', 200);
+				return true;
+			} catch (PusherException $e) {
+				$this->setAPIResponse('error', 'Chat message error', 500);
+			}
 		}
 		$this->setAPIResponse('error', 'Chat error occurred', 409);
 		return false;

+ 4 - 0
api/plugins/config/bookmark.php

@@ -0,0 +1,4 @@
+<?php
+return array(
+    'BOOKMARK-enabled' => false
+);

+ 70 - 0
api/plugins/css/bookmark.css

@@ -0,0 +1,70 @@
+#BOOKMARK-wrapper {
+    display: flex;
+    flex-direction: column;
+    justify-content: flex-start;
+}
+
+.BOOKMARK-category {
+    text-align: center;
+    margin-bottom: 40px;
+}
+
+.BOOKMARK-category-title {
+    font-weight: 500;
+    color: #ddd;
+    font-size: large;
+}
+
+.BOOKMARK-category-content {
+    width: 80%;
+    margin: 0 auto;
+    display: flex;
+    flex-flow: row wrap;
+    justify-content: center;
+}
+
+.BOOKMARK-tab {
+    display: inline-flex;
+    justify-content: space-between;
+    align-items: center;
+    margin: 10px 10px 0 10px;
+    height: 50px;
+    width: 200px;
+    overflow: hidden;
+    border: 1px solid;
+    border-radius: 5px;
+    transition: all 0.2s ease-in-out;
+}
+
+.BOOKMARK-tab:hover {
+    filter: brightness(80%);
+}
+
+.BOOKMARK-tab-image {
+    width: 50px;
+    max-width: 50px;
+    height: 100%;
+    flex-grow: 33;
+}
+
+.BOOKMARK-tab-image img {
+    width: 100%;
+    height: 100%;
+    padding: 8px;
+    object-fit: contain;
+}
+
+.BOOKMARK-tab-image i {
+    width: 100%;
+    height: 100%;
+    line-height: 44px;
+    font-size: 2.2em;
+}
+
+.BOOKMARK-tab-title {
+    flex-grow: 67;
+    padding: 0 5px;
+    color: white;
+    text-align: left;
+    font-weight: 500;
+}

+ 18 - 10
api/plugins/healthChecks.php

@@ -7,10 +7,12 @@ $GLOBALS['plugins'][]['healthChecks'] = array( // Plugin Name
 	'link' => '', // Link to plugin info
 	'license' => 'personal,business', // License Type use , for multiple
 	'idPrefix' => 'HEALTHCHECKS', // html element id prefix
-	'configPrefix' => 'HEALTHCHECKS', // config file prefix for array items without the hypen
+	'configPrefix' => 'HEALTHCHECKS', // config file prefix for array items without the hyphen
 	'version' => '1.0.0', // SemVer of plugin
 	'image' => 'plugins/images/healthchecksio.png', // 1:1 non transparent image for plugin
-	'settings' => true, // does plugin need a settings page? true or false
+	'settings' => true, // does plugin need a settings modal?
+	'bind' => false, // use default bind to make settings page - true or false
+	'api' => false, // api route for settings page
 	'homepage' => false // Is plugin for use on homepage? true or false
 );
 
@@ -53,12 +55,17 @@ class HealthChecks extends Organizr
 		$options = array('verify' => false, 'verifyname' => false, 'follow_redirects' => true, 'redirects' => 1);
 		$headers = array('Token' => $this->config['organizrAPI']);
 		$url = $this->qualifyURL($url);
-		$response = Requests::get($url, $headers, $options);
-		if ($response->success) {
-			$success = true;
-		}
-		if ($response->status_code == 200) {
-			$success = true;
+		try {
+			$response = Requests::get($url, $headers, $options);
+			if ($response->success) {
+				$success = true;
+			}
+			if ($response->status_code == 200) {
+				$success = true;
+			}
+		} catch (Requests_Exception $e) {
+			$this->writeLog('error', 'HealthChecks Plugin - Error: ' . $e->getMessage(), 'SYSTEM');
+			return false;
 		}
 		return $success;
 	}
@@ -71,7 +78,8 @@ class HealthChecks extends Organizr
 		$url = $this->qualifyURL($this->config['HEALTHCHECKS-PingURL']);
 		$uuid = '/' . $uuid;
 		$path = !$pass ? '/fail' : '';
-		return Requests::get($url . $uuid . $path, [], []);
+		$options = ($this->localURL($url)) ? array('verify' => false) : array('verify' => $this->getCert());
+		return Requests::get($url . $uuid . $path, [], $options);
 	}
 	
 	public function _healthCheckPluginRun()
@@ -133,4 +141,4 @@ class HealthChecks extends Organizr
 			$this->setAPIResponse('error', 'User does not have access', 401);
 		}
 	}
-}
+}

+ 4 - 2
api/plugins/invites.php

@@ -10,7 +10,9 @@ $GLOBALS['plugins'][]['Invites'] = array( // Plugin Name
 	'configPrefix' => 'INVITES', // config file prefix for array items without the hypen
 	'version' => '1.0.0', // SemVer of plugin
 	'image' => 'plugins/images/invites.png', // 1:1 non transparent image for plugin
-	'settings' => true, // does plugin need a settings page? true or false
+	'settings' => true, // does plugin need a settings modal?
+	'bind' => true, // use default bind to make settings page - true or false
+	'api' => 'api/v2/plugins/invites/settings', // api route for settings page
 	'homepage' => false // Is plugin for use on homepage? true or false
 );
 
@@ -470,4 +472,4 @@ class Invites extends Organizr
 		return (!empty($plexUser) ? $plexUser : null);
 	}
 	
-}
+}

+ 571 - 0
api/plugins/js/bookmark-settings.js

@@ -0,0 +1,571 @@
+/* BOOKMARK JS FILE */
+// FUNCTIONS
+bookmarkLaunch();
+$('body').arrive('#settings-main-tab-editor .nav-tabs', {onceOnly: true}, function() {
+	bookmarkLaunch();
+});
+function bookmarkCheckForTab() {
+	// Let check for tab with bookmark url
+	organizrAPI2('GET', 'api/v2/plugins/bookmark/setup/tab').success(function (data) {
+		try {
+			let response = data.response;
+			$('.bookmark-check-tab small').text('Bookmark Tab');
+			$('.bookmark-check-tab .result').text(response.message);
+		} catch (e) {
+			organizrCatchError(e, data);
+		}
+	}).fail(function (xhr) {
+		OrganizrApiError(xhr);
+		$('.bookmark-check-tab .result').text('Error...');
+	});
+}
+$('body').arrive('.bookmark-check-tab', {onceOnly: false}, function() {
+	setTimeout(function(){
+		bookmarkCheckForTab()
+		bookmarkCheckForCategory();
+	}, 500);
+
+});
+function bookmarkCheckForCategory(){
+	// Let check for tab with bookmark url
+	organizrAPI2('GET','api/v2/plugins/bookmark/setup/category').success(function(data) {
+		try {
+			let response = data.response;
+			$('.bookmark-check-category small').text('Bookmark Categories');
+			$('.bookmark-check-category .result').text(response.message);
+		}catch(e) {
+			organizrCatchError(e,data);
+		}
+	}).fail(function(xhr) {
+		OrganizrApiError(xhr);
+		$('.bookmark-check-category .result').text('Error...');
+	});
+}
+function bookmarkLaunch(){
+	if(activeInfo.plugins["BOOKMARK-enabled"] == true){
+		bookmarkTabsLaunch();
+		bookmarkCategoriesLaunch();
+		pageLoad();
+	}
+}
+
+// TAB MANAGEMENT
+function bookmarkTabsLaunch(){
+	var menuList = `<li onclick="changeSettingsMenu('Settings::Tab Editor::Bookmark Tabs');loadSettingsPage2('api/v2/plugins/bookmark/settings_tab_editor_bookmark_tabs','#settings-tab-editor-tabs','Tab Editor');" role="presentation"><a id="settings-tab-editor-tabs-anchor" href="#settings-tab-editor-tabs" aria-controls="home" role="tab" data-toggle="tab" aria-expanded="true"><span class="visible-xs"><i class="ti-layout-tab-v"></i></span><span class="hidden-xs" lang="en">Bookmark Tabs</span></a></li>`;
+	$('#settings-main-tab-editor .nav-tabs').append(menuList);
+}
+
+function buildBookmarkTabEditor(){
+	organizrAPI2('GET','api/v2/plugins/bookmark/tabs').success(function(data) {
+		try {
+			var response = data.response;
+		}catch(e) {
+			organizrCatchError(e,data);
+		}
+		$('#bookmarkTabEditorTable').html(buildBookmarkTabEditorItem(response.data));
+	}).fail(function(xhr) {
+		OrganizrApiError(xhr);
+	});
+}
+
+function buildBookmarkTabEditorItem(array){
+	var tabList = '';
+	$.each(array.tabs, function(i,v) {
+		tabList += `
+		<tr class="bookmarkTabEditor" data-order="`+v.order+`" data-original-order="`+v.order+`" data-id="`+v.id+`" data-group-id="`+v.group_id+`" data-category-id="`+v.category_id+`" data-name="`+v.name+`" data-url="`+v.url+`" data-image="`+v.image+`">
+			<input type="hidden" class="form-control" name="tab[`+v.id+`].id" value="`+v.id+`">
+			<input type="hidden" class="form-control order" name="tab[`+v.id+`].order" value="`+v.order+`">
+			<input type="hidden" class="form-control" name="tab[`+v.id+`].originalOrder" value="`+v.order+`">
+			<td style="text-align:center" class="text-center el-element-overlay">
+				<div class="el-card-item p-0">
+					<div class="el-card-avatar el-overlay-1 m-0">
+						<div class="bookmarkTabEditorIcon">`+iconPrefix(v.image)+`</div>
+						<div class="el-overlay bg-org">
+							<ul class="el-info">
+								<i class="fa fa-bars"></i>
+							</ul>
+						</div>
+					</div>
+				</div>
+			</td>
+			<td><span class="tooltip-info" data-toggle="tooltip" data-placement="right" title="" data-original-title="`+v.url+`">`+v.name+`</span></td>
+            `+buildBookmarkTabCategorySelect(array.categories,v.id, v.category_id)+`
+			`+buildBookmarkTabGroupSelect(array.groups,v.id, v.group_id)+`
+			<td style="text-align:center"><input type="checkbox" class="js-switch bookmarkEnabledSwitch" data-size="small" data-color="#99d683" data-secondary-color="#f96262" name="tab[`+v.id+`].enabled" value="true" `+tof(v.enabled,'c')+`/><input type="hidden" class="form-control" name="tab[`+v.id+`].enabled" value="false"></td>
+			<td style="text-align:center"><button type="button" class="btn btn-info btn-outline btn-circle btn-lg m-r-5 editBookmarkTabButton popup-with-form" onclick="editBookmarkTabForm('`+v.id+`')" href="#edit-bookmark-tab-form" data-effect="mfp-3d-unfold"><i class="ti-pencil-alt"></i></button></td>
+			<td style="text-align:center"><button type="button" class="btn btn-danger btn-outline btn-circle btn-lg m-r-5 bookmarkDeleteTab"><i class="ti-trash"></i></button></td>
+		</tr>
+		`;
+	});
+	return tabList;
+}
+
+function buildBookmarkTabGroupSelect(array, tabID, groupID){
+	var groupSelect = '';
+	var selected = '';
+	$.each(array, function(i,v) {
+		selected = '';
+		if(v.group_id == groupID){
+			selected = 'selected';
+		}
+		groupSelect += '<option '+selected+' value="'+v.group_id+'">'+v.group+'</option>';
+	});
+	return '<td><select name="tab['+tabID+'].group_id" class="form-control bookmarkTabGroupSelect">'+groupSelect+'</select></td>';
+}
+
+function buildBookmarkTabCategorySelect(array,tabID, categoryID){
+	var categorySelect = '';
+	var selected = '';
+	$.each(array, function(i,v) {
+		selected = '';
+		if(v.category_id == categoryID){
+			selected = 'selected';
+		}
+		categorySelect += '<option '+selected+' value="'+v.category_id+'">'+v.category+'</option>';
+	});
+	return '<td><select name="tab['+tabID+'].category_id" class="form-control bookmarkTabCategorySelect">'+categorySelect+'</select></td>';
+}
+
+function editBookmarkTabForm(id){
+	organizrAPI2('GET','api/v2/plugins/bookmark/tabs/' + id,true).success(function(data) {
+		try {
+			let response = data.response;
+			console.log(response);
+			$('#edit-bookmark-tab-form [name=name]').val(response.data.name);
+			$('#originalBookmarkTabName').html(response.data.name);
+			$('#edit-bookmark-tab-form [name=url]').val(response.data.url);
+			$('#edit-bookmark-tab-form [name=image]').val(response.data.image);
+			$('#edit-bookmark-tab-form [name=background_color]').val(response.data.background_color);
+			$('#edit-bookmark-tab-form [name=text_color]').val(response.data.text_color);
+			$('#edit-bookmark-tab-form [name=id]').val(response.data.id);
+			if( response.data.url.indexOf('/?v') > 0){
+				$('#edit-bookmark-tab-form [name=url]').prop('disabled', 'true');
+			}else{
+				$('#edit-bookmark-tab-form [name=url]').prop('disabled', null);
+			}
+		}catch(e) {
+			organizrCatchError(e,data);
+		}
+	}).fail(function(xhr) {
+		OrganizrApiError(xhr, 'Tab Error');
+	});
+}
+
+// CHANGE ENABLED TAB
+$(document).on("change", ".bookmarkEnabledSwitch", function () {
+	var id = $(this).parent().parent().attr("data-id");
+	var enabled = $(this).prop("checked") ? 1 : 0;
+	var callbacks = $.Callbacks();
+	organizrAPI2('PUT','api/v2/plugins/bookmark/tabs/' + id, {"enabled":enabled},true).success(function(data) {
+		try {
+			var response = data.response;
+		}catch(e) {
+			organizrCatchError(e,data);
+		}
+		message('Tab Enable Updated',response.message,activeInfo.settings.notifications.position,"#FFF","success","5000");
+		if(callbacks){ callbacks.fire(); }
+	}).fail(function(xhr) {
+		OrganizrApiError(xhr, 'Tab Enable Error');
+	});
+});
+// CHANGE TAB GROUP
+$(document).on("change", ".bookmarkTabGroupSelect", function (event) {
+	var id = $(this).parent().parent().attr("data-id");
+	var groupID = $(this).find("option:selected").val();
+	var callbacks = $.Callbacks();
+	organizrAPI2('PUT','api/v2/plugins/bookmark/tabs/' + id, {"group_id":groupID},true).success(function(data) {
+		try {
+			var response = data.response;
+		}catch(e) {
+			organizrCatchError(e,data);
+		}
+		message('Tab Group Updated',response.message,activeInfo.settings.notifications.position,"#FFF","success","5000");
+		if(callbacks){ callbacks.fire(); }
+	}).fail(function(xhr) {
+		OrganizrApiError(xhr, 'Tab Group Error');
+	});
+});
+// CHANGE TAB CATEGORY
+$(document).on("change", ".bookmarkTabCategorySelect", function () {
+	var id = $(this).parent().parent().attr("data-id");
+	var categoryID = $(this).find("option:selected").val();
+	console.log("CategoryID: " + categoryID);
+	var callbacks = $.Callbacks();
+	organizrAPI2('PUT','api/v2/plugins/bookmark/tabs/' + id, {"category_id":categoryID},true).success(function(data) {
+		try {
+			var response = data.response;
+		}catch(e) {
+			organizrCatchError(e,data);
+		}
+		message('Tab Category Updated',response.message,activeInfo.settings.notifications.position,"#FFF","success","5000");
+		if(callbacks){ callbacks.fire(); }
+	}).fail(function(xhr) {
+		OrganizrApiError(xhr, 'Tab Category Error');
+	});
+});
+//DELETE TAB
+$(document).on("click", ".bookmarkDeleteTab", function () {
+	var tab = $(this);
+	swal({
+		title: window.lang.translate('Delete ') + tab.parent().parent().attr("data-name") + '?',
+		icon: "warning",
+		buttons: {
+			cancel: window.lang.translate('No'),
+			confirm: window.lang.translate('Yes'),
+		},
+		dangerMode: true,
+		confirmButtonColor: "#DD6B55"
+	}).then(function(willDelete) {
+		if (willDelete) {
+			var id = tab.parent().parent().attr("data-id");
+			var callbacks = $.Callbacks();
+			callbacks.add( buildBookmarkTabEditor );
+			organizrAPI2('DELETE','api/v2/plugins/bookmark/tabs/' + id, null,true).success(function(data) {
+				message('Tab Deleted','',activeInfo.settings.notifications.position,"#FFF","success","5000");
+				if(callbacks){ callbacks.fire(); }
+			}).fail(function(xhr) {
+				OrganizrApiError(xhr, 'Tab Deleted Error');
+			});
+		}
+	});
+});
+//EDIT TAB
+$(document).on("click", ".editBookmarkTab", function () {
+	var originalTabName = $('#originalBookmarkTabName').html();
+	var tabInfo = $('#edit-bookmark-tab-form').serializeToJSON();
+	if (typeof tabInfo.id == 'undefined' || tabInfo.id == '') {
+		message('Edit Tab Error',' Could not get Tab ID',activeInfo.settings.notifications.position,'#FFF','error','5000');
+		return false;
+	}
+	if (typeof tabInfo.name == 'undefined' || tabInfo.name == '') {
+		message('Edit Tab Error',' Please set a Tab Name',activeInfo.settings.notifications.position,'#FFF','warning','5000');
+		return false;
+	}
+	if (typeof tabInfo.image == 'undefined' || tabInfo.image == '') {
+		message('Edit Tab Error',' Please set a Tab Image',activeInfo.settings.notifications.position,'#FFF','warning','5000');
+		return false;
+	}
+	if (typeof tabInfo.url == 'undefined' || tabInfo.url == '') {
+		message('Edit Tab Error',' Please set a Tab URL',activeInfo.settings.notifications.position,'#FFF','warning','5000');
+		return false;
+	}
+	if (typeof tabInfo.background_color == 'undefined' || tabInfo.background_color == '') {
+		message('Edit Tab Error',' Please set a Background Color',activeInfo.settings.notifications.position,'#FFF','warning','5000');
+		return false;
+	}
+	if (typeof tabInfo.text_color == 'undefined' || tabInfo.text_color == '') {
+		message('Edit Tab Error',' Please set a Text Color',activeInfo.settings.notifications.position,'#FFF','warning','5000');
+		return false;
+	}
+	if(tabInfo.id !== '' && tabInfo.tabName !== '' && tabInfo.tabImage !== '' && tabInfo.background_color !== '' && tabInfo.text_color !== ''){
+		var callbacks = $.Callbacks();
+		callbacks.add( buildBookmarkTabEditor );
+		organizrAPI2('PUT','api/v2/plugins/bookmark/tabs/' + tabInfo.id,tabInfo,true).success(function(data) {
+			try {
+				var response = data.response;
+				console.log(response);
+			}catch(e) {
+				organizrCatchError(e,data);
+			}
+			message('Tab Updated',response.message,activeInfo.settings.notifications.position,"#FFF","success","5000");
+			if(callbacks){ callbacks.fire(); }
+			clearForm('#edit-bookmark-tab-form');
+			$.magnificPopup.close();
+		}).fail(function(xhr) {
+			OrganizrApiError(xhr, 'Tab Error');
+		});
+	}
+});
+//ADD NEW TAB
+$(document).on("click", ".addNewBookmarkTab", function () {
+	var tabInfo = $('#new-bookmark-tab-form').serializeToJSON();
+	var order = parseInt($('#bookmarkTabEditorTable').find('tr[data-order]').last().attr('data-order')) + 1;
+	tabInfo['order'] = isNaN(order) ? 1 : order;
+
+	if (typeof tabInfo.name == 'undefined' || tabInfo.name == '') {
+		message('Edit Tab Error',' Please set a Tab Name',activeInfo.settings.notifications.position,'#FFF','warning','5000');
+		return false;
+	}
+	if (typeof tabInfo.image == 'undefined' || tabInfo.image == '') {
+		message('Edit Tab Error',' Please set a Tab Image',activeInfo.settings.notifications.position,'#FFF','warning','5000');
+		return false;
+	}
+	if ((typeof tabInfo.url == 'undefined' || tabInfo.url == '')) {
+		message('Edit Tab Error',' Please set a Tab URL',activeInfo.settings.notifications.position,'#FFF','warning','5000');
+		return false;
+	}
+	if (typeof tabInfo.background_color == 'undefined' || tabInfo.background_color == '') {
+		message('Edit Tab Error',' Please set a Background Color',activeInfo.settings.notifications.position,'#FFF','warning','5000');
+		return false;
+	}
+	if (typeof tabInfo.text_color == 'undefined' || tabInfo.text_color == '') {
+		message('Edit Tab Error',' Please set a Text Color',activeInfo.settings.notifications.position,'#FFF','warning','5000');
+		return false;
+	}
+	if(tabInfo.order !== '' && tabInfo.name !== '' && tabInfo.url !== '' && tabInfo.image !== '' && tabInfo.background_color !== '' && tabInfo.text_color !== ''){
+		var callbacks = $.Callbacks();
+		callbacks.add( buildBookmarkTabEditor );
+		organizrAPI2('POST','api/v2/plugins/bookmark/tabs',tabInfo,true).success(function(data) {
+			try {
+				var response = data.response;
+				$('.bookmarkTabIconImageList').val(null).trigger('change');
+				$('.bookmarkTabIconIconList').val(null).trigger('change');
+			}catch(e) {
+				organizrCatchError(e,data);
+			}
+			message('Tab Created',response.message,activeInfo.settings.notifications.position,"#FFF","success","5000");
+			if(callbacks){ callbacks.fire(); }
+			clearForm('#new-bookmark-tab-form');
+			$.magnificPopup.close();
+		}).fail(function(xhr) {
+			OrganizrApiError(xhr, 'Tab Error');
+		});
+	}
+});
+// CHANGE TAB ORDER
+function submitBookmarkTabOrder(newTabs){
+	var data = [];
+	var process = false;
+	$.each(newTabs.tab, function(i,v) {
+		if(v.originalOrder == v.order){
+			delete newTabs.tab[i];
+		}else{
+			let temp = {
+				"order":v.order,
+				"id":v.id
+			}
+			data.push(temp);
+			process = true;
+		}
+	})
+	if(!process){
+		message('Tab Order Warning','Order was not changed - Submission not needed',activeInfo.settings.notifications.position,"#FFF","warning","5000");
+		$('.saveBookmarkTabOrderButton').addClass('hidden');
+		return false;
+	}
+	var callbacks = $.Callbacks();
+	callbacks.add( buildBookmarkTabEditor );
+	organizrAPI2('PUT','api/v2/plugins/bookmark/tabs',data,true).success(function(data) {
+		try {
+			var response = data.response;
+		}catch(e) {
+			organizrCatchError(e,data);
+		}
+		message('Tab Order Updated',response.message,activeInfo.settings.notifications.position,"#FFF","success","5000");
+		if(callbacks){ callbacks.fire(); }
+		$('.saveBookmarkTabOrderButton').addClass('hidden');
+	}).fail(function(xhr) {
+		OrganizrApiError(xhr, 'Update Error');
+	});
+}
+
+$(document).on('change', "#new-bookmark-tab-form-chooseImage", function (e) {
+	var newIcon = $('#new-bookmark-tab-form-chooseImage').val();
+	if(newIcon !== 'Select or type Icon'){
+		$('#new-bookmark-tab-form-inputImageNew').val(newIcon);
+	}
+});
+$(document).on('change', "#edit-bookmark-tab-form-chooseImage", function (e) {
+	var newIcon = $('#edit-bookmark-tab-form-chooseImage').val();
+	if(newIcon !== 'Select or type Icon'){
+		$('#edit-bookmark-tab-form-inputImage').val(newIcon);
+	}
+});
+$(document).on('change', "#new-bookmark-tab-form-chooseIcon", function (e) {
+	var newIcon = $('#new-bookmark-tab-form-chooseIcon').val();
+	if(newIcon !== 'Select or type Icon'){
+		$('#new-bookmark-tab-form-inputImageNew').val(newIcon);
+	}
+});
+$(document).on('change', "#edit-bookmark-tab-form-chooseIcon", function (e) {
+	var newIcon = $('#edit-bookmark-tab-form-chooseIcon').val();
+	if(newIcon !== 'Select or type Icon'){
+		$('#edit-bookmark-tab-form-inputImage').val(newIcon);
+	}
+});
+
+// CATEGORY MANAGEMENT
+function bookmarkCategoriesLaunch(){
+	var menuList = `<li onclick="changeSettingsMenu('Settings::Tab Editor::Bookmark Categories');loadSettingsPage2('api/v2/plugins/bookmark/settings_tab_editor_bookmark_categories','#settings-tab-editor-tabs','Tab Editor');" role="presentation"><a id="settings-tab-editor-tabs-anchor" href="#settings-tab-editor-tabs" aria-controls="home" role="tab" data-toggle="tab" aria-expanded="true"><span class="visible-xs"><i class="ti-layout-tab-v"></i></span><span class="hidden-xs" lang="en">Bookmark Categories</span></a></li>`;
+	$('#settings-main-tab-editor .nav-tabs').append(menuList);
+}
+
+function buildBookmarkCategoryEditor(){
+	organizrAPI2('GET','api/v2/plugins/bookmark/tabs').success(function(data) {
+		try {
+			var response = data.response;
+		}catch(e) {
+			organizrCatchError(e,data);
+		}
+		$('#bookmarkCategoryEditorTable').html(buildBookmarkCategoryEditorItem(response.data));
+	}).fail(function(xhr) {
+		OrganizrApiError(xhr);
+	});
+}
+
+function buildBookmarkCategoryEditorItem(array){
+	var categoryList = '';
+	$.each(array.categories, function(i,v) {
+		var tabCount = array.tabs.reduce(function (n, category) {
+			return n + (category.category_id == v.category_id);
+		}, 0);
+		var disabledDefault = (v.default == 1) ? 'disabled' : '';
+		var disabledDelete = (tabCount > 0) ? 'disabled' : '';
+		var defaultIcon = (v.default == 1) ? 'icon-user-following' : 'icon-user-follow';
+		var defaultColor = (v.default == 1) ? 'btn-info disabled' : 'btn-warning';
+		categoryList += `
+		<tr class="bookmarkCategoryEditor" data-id="`+v.id+`" data-order="`+v.order+`" data-category-id="`+v.category_id+`" data-name="`+v.category+`" data-default="`+tof(v.default)+`" data-tab-count="`+tabCount+`">
+			<input type="hidden" class="form-control order" name="category[`+v.id+`].order" value="`+v.order+`">
+			<input type="hidden" class="form-control" name="category[`+v.id+`].originalOrder" value="`+v.order+`">
+			<input type="hidden" class="form-control" name="category[`+v.id+`].name" value="`+v.category+`">
+			<input type="hidden" class="form-control" name="category[`+v.id+`].id" value="`+v.id+`">
+			<td>`+v.category+`</td>
+			<td style="text-align:center">`+tabCount+`</td>
+			<td style="text-align:center"><button type="button" class="btn `+defaultColor+` btn-outline btn-circle btn-lg m-r-5 changeDefaultBookmarkCategory" `+disabledDefault+`><i class="`+defaultIcon+`"></i></button></td>
+			<td style="text-align:center"><button type="button" class="btn btn-info btn-outline btn-circle btn-lg m-r-5 editBookmarkCategoryButton popup-with-form" href="#edit-bookmark-category-form" data-effect="mfp-3d-unfold"><i class="ti-pencil-alt"></i></button></td>
+			<td style="text-align:center"><button type="button" class="btn btn-danger btn-outline btn-circle btn-lg m-r-5 deleteBookmarkCategory" `+disabledDelete+`><i class="ti-trash"></i></button></td>
+		</tr>
+		`;
+	});
+	return categoryList;
+}
+
+//ADD NEW CATEGORY
+$(document).on("click", ".addNewBookmarkCategory", function () {
+	var categoryInfo = $('#new-bookmark-category-form').serializeToJSON();
+	var order = parseInt($('#bookmarkCategoryEditorTable').find('tr[data-order]').last().attr('data-order')) + 1;
+	categoryInfo['order'] = isNaN(order) ? 1 : order;
+
+	if (typeof categoryInfo.category == 'undefined' || categoryInfo.category == '') {
+		message('Edit Tab Error',' Please set a Category Name',activeInfo.settings.notifications.position,'#FFF','warning','5000');
+		return false;
+	}
+	if(categoryInfo.category !== ''){
+		var callbacks = $.Callbacks();
+		callbacks.add( buildBookmarkCategoryEditor );
+		organizrAPI2('POST','api/v2/plugins/bookmark/categories',categoryInfo,true).success(function(data) {
+			try {
+				var response = data.response;
+				console.log(response);
+			}catch(e) {
+				organizrCatchError(e,data);
+			}
+			message('Category Added',response.message,activeInfo.settings.notifications.position,"#FFF","success","5000");
+			if(callbacks){ callbacks.fire(); }
+			clearForm('#new-bookmark-category-form');
+			$.magnificPopup.close();
+		}).fail(function(xhr) {
+			OrganizrApiError(xhr, 'Category Error');
+		});
+	}
+});
+//DELETE CATEGORY
+$(document).on("click", ".deleteBookmarkCategory", function () {
+	var category = $(this);
+	swal({
+		title: window.lang.translate('Delete ')+category.parent().parent().attr("data-name")+'?',
+		icon: "warning",
+		buttons: {
+			cancel: window.lang.translate('No'),
+			confirm: window.lang.translate('Yes'),
+		},
+		dangerMode: true,
+		confirmButtonColor: "#DD6B55"
+	}).then(function(willDelete) {
+		if (willDelete) {
+			var id = category.parent().parent().attr("data-id");
+			var callbacks = $.Callbacks();
+			callbacks.add( buildBookmarkCategoryEditor );
+			organizrAPI2('DELETE','api/v2/plugins/bookmark/categories/' + id, null,true).success(function(data) {
+				message('Category Deleted','',activeInfo.settings.notifications.position,"#FFF","success","5000");
+				if(callbacks){ callbacks.fire(); }
+			}).fail(function(xhr) {
+				OrganizrApiError(xhr, 'Category Deleted Error');
+			});
+		}
+	});
+});
+//EDIT CATEGORY GET ID
+$(document).on("click", ".editBookmarkCategoryButton", function () {
+	$('#edit-bookmark-category-form [name=category]').val($(this).parent().parent().attr("data-name"));
+	$('#edit-bookmark-category-form [name=id]').val($(this).parent().parent().attr("data-id"));
+});
+//EDIT CATEGORY
+$(document).on("click", ".editBookmarkCategory", function () {
+	var categoryInfo = $('#edit-bookmark-category-form').serializeToJSON();
+	if (typeof categoryInfo.id == 'undefined' || categoryInfo.id == '') {
+		message('Edit Tab Error',' Could not get Category ID',activeInfo.settings.notifications.position,'#FFF','error','5000');
+		return false;
+	}
+	if (typeof categoryInfo.category == 'undefined' || categoryInfo.category == '') {
+		message('Edit Tab Error',' Please set a Category Name',activeInfo.settings.notifications.position,'#FFF','warning','5000');
+		return false;
+	}
+	if(categoryInfo.id !== '' && categoryInfo.category !== ''){
+		var callbacks = $.Callbacks();
+		callbacks.add( buildBookmarkCategoryEditor );
+		organizrAPI2('PUT','api/v2/plugins/bookmark/categories/' + categoryInfo.id,categoryInfo,true).success(function(data) {
+			try {
+				var response = data.response;
+				console.log(response);
+			}catch(e) {
+				organizrCatchError(e,data);
+			}
+			message('Category Updated',response.message,activeInfo.settings.notifications.position,"#FFF","success","5000");
+			if(callbacks){ callbacks.fire(); }
+			clearForm('#edit-bookmark-category-form');
+			$.magnificPopup.close();
+		}).fail(function(xhr) {
+			OrganizrApiError(xhr, 'Category Error');
+		});
+	}
+});
+//CHANGE DEFAULT CATEGORY
+$(document).on("click", ".changeDefaultBookmarkCategory", function () {
+	var id = $(this).parent().parent().attr("data-id");
+	var callbacks = $.Callbacks();
+	callbacks.add( buildBookmarkCategoryEditor );
+	organizrAPI2('PUT','api/v2/plugins/bookmark/categories/' + id, {"default":1},true).success(function(data) {
+		try {
+			var response = data.response;
+		}catch(e) {
+			organizrCatchError(e,data);
+		}
+		message('Default Category Updated',response.message,activeInfo.settings.notifications.position,"#FFF","success","5000");
+		if(callbacks){ callbacks.fire(); }
+	}).fail(function(xhr) {
+		OrganizrApiError(xhr, 'Default Cateogry Error');
+	});
+});
+// CHANGE CATEGORY ORDER
+function submitBookmarkCategoryOrder(){
+	var data = [];
+	var categories = $( "#submit-bookmark-categories-form" ).serializeToJSON();
+	var callbacks = $.Callbacks();
+	callbacks.add( buildCategoryEditor );
+	$.each(categories.category, function(i,v) {
+		if(v.originalOrder == v.order){
+			delete categories.category[i];
+		}else{
+			let temp = {
+				"order":v.order,
+				"id":v.id
+			}
+			data.push(temp);
+		}
+	})
+	organizrAPI2('PUT','api/v2/plugins/bookmark/categories',data,true).success(function(data) {
+		try {
+			var response = data.response;
+		}catch(e) {
+			organizrCatchError(e,data);
+		}
+		message('Category Order Updated',response.message,activeInfo.settings.notifications.position,"#FFF","success","5000");
+		if(callbacks){ callbacks.fire(); }
+		$('.saveTabOrderButton').addClass('hidden');
+	}).fail(function(xhr) {
+		OrganizrApiError(xhr, 'Update Error');
+	});
+}
+
+// TAB MANAGEMENT

+ 168 - 182
api/plugins/js/chat.js

@@ -1,200 +1,186 @@
 // FUNCTIONS FOR CHAT
-chatLaunch()
+$('body').arrive('#activeInfo', {onceOnly: true}, function() {
+	chatLaunch();
+});
 function chatLaunch(){
-    if(typeof activeInfo == 'undefined'){
-        setTimeout(function () {
-            chatLaunch();
-        }, 1000);
-    }else{
-        if(activeInfo.plugins["CHAT-enabled"] == true && activeInfo.plugins.includes["CHAT-authKey-include"] !== '' && activeInfo.plugins.includes["CHAT-appID-include"] !== '' && activeInfo.plugins.includes["CHAT-cluster-include"] !== ''){
-            if (activeInfo.user.groupID <= activeInfo.plugins.includes["CHAT-Auth-include"]) {
-                var menuList = `<li><a class=""  href="javascript:void(0)" onclick="tabActions(event,'chat','plugin');chatEntry();"><i class="fa fa-comments-o fa-fw"></i> <span lang="en">Chat</span><small class="chat-counter label label-rouded label-info pull-right hidden">0</small></a></li>`;
-				var htmlDOM = `
-                <div id="container-plugin-chat" class="plugin-container hidden">
-                    <div class="chat-main-box bg-org">
-                        <!-- .chat-left-panel -->
-                        <div class="chat-left-aside">
-                            <div class="open-panel"><i class="ti-angle-right"></i></div>
-                            <div class="chat-left-inner bg-org"><ul class="chatonline style-none "></ul></div>
-                        </div>
-                        <!-- .chat-left-panel -->
-                        <!-- .chat-right-panel -->
-                        <div class="chat-right-aside">
-                            <div class="chat-box">
-                                <ul class="chat-list p-t-30"></ul>
-                                <div class="row send-chat-box">
-                                    <div class="col-sm-12">
-                                        <textarea class="form-control chat-input-send" placeholder="Type your message"></textarea>
-                                        <div class="custom-send">
-                                            <button type="button" class="btn btn-info btn-lg custom-send-button"><i class="fa fa-paper-plane fa-2x"></i> </button>
-                                        </div>
-                                    </div>
-                                </div>
-                            </div>
-                        </div>
-                        <!-- .chat-right-panel -->
-                    </div>
-                </div>
-		    	`;
-				$('.append-menu').after(menuList);
-	            $('.plugin-listing').append(htmlDOM);
-	            pageLoad();
-                // Enable pusher logging - don't include this in production
-                //Pusher.logToConsole = true;
-                // Add API Key & cluster here to make the connection
-                var pusher = new Pusher(activeInfo.plugins.includes["CHAT-authKey-include"], {
-                    cluster: activeInfo.plugins.includes["CHAT-cluster-include"],
-                    encrypted: true
-                });
-                // Enter a unique channel you wish your users to be subscribed in.
-                var channel = pusher.subscribe('org_channel');
-                // bind the server event to get the response data and append it to the message div
-                channel.bind('my-event',
-                    function(data) {
-                        formatMessage(data);
-                        $('.chat-list').append(formatMessage(data));
-                        $('.custom-send').html('<button type="button" class="btn btn-info btn-lg custom-send-button"><i class="fa fa-paper-plane fa-2x"></i> </button>');
-                        $(".chat-list").scrollTop($(".chat-list")[0].scrollHeight);
-                        if($('#container-plugin-chat').hasClass('hidden')){
-                            var chatSound =  new Audio(activeInfo.plugins.includes["CHAT-newMessageSound-include"]);
-                            chatSound.play();
-                            message(data.username,data.message,activeInfo.settings.notifications.position,"#FFF","success","20000");
-                            $('.profile-image').addClass('animated loop-animation rubberBand');
-                            $('.chat-counter').removeClass('hidden').html(parseInt($('.chat-counter').text()) + 1);
-                        }
-                    });
-                // check if the user is subscribed to the above channel
-                channel.bind('pusher:subscription_succeeded', function(members) {
-	                organizrConsole('Plugin Function','Chat Websocket Connected!');
-	                organizrConsole('Plugin Function','Connecting to Organizr Chat DB');
-                    getMessagesAndUsers(activeInfo.settings.homepage.refresh["CHAT-userRefreshTimeout"], true);
-                });
-                /*jslint browser: true*/
-                /*global $, jQuery, alert*/
-                $(document).ready(function () {
-                    "use strict";
-                    $('.chat-left-inner > .chatonline').slimScroll({
-                        height: '100%',
-                        position: 'right',
-                        size: "0px",
-                        color: '#dcdcdc'
-
-                    });
-                    $('.chat-list').slimScroll({
-                        height: '100%',
-                        position: 'right',
-                        size: "0px",
-                        color: '#dcdcdc',
-                        start: 'bottom',
-                    });
-                    $(".open-panel").on("click", function () {
-                        $(".chat-left-aside").toggleClass("open-pnl");
-                        $(".open-panel i").toggleClass("ti-angle-left");
-                    });
-                });
-			}
-        }
-    }
+	if(activeInfo.plugins["CHAT-enabled"] == true && activeInfo.plugins.includes["CHAT-authKey-include"] !== '' && activeInfo.plugins.includes["CHAT-appID-include"] !== '' && activeInfo.plugins.includes["CHAT-cluster-include"] !== ''){
+		if (activeInfo.user.groupID <= activeInfo.plugins.includes["CHAT-Auth-include"]) {
+			var menuList = `<li><a class=""  href="javascript:void(0)" onclick="tabActions(event,'chat','plugin');chatEntry();"><i class="fa fa-comments-o fa-fw"></i> <span lang="en">Chat</span><small class="chat-counter label label-rouded label-info pull-right hidden">0</small></a></li>`;
+			var htmlDOM = `
+			<div id="container-plugin-chat" class="plugin-container hidden">
+				<div class="chat-main-box bg-org">
+					<!-- .chat-left-panel -->
+					<div class="chat-left-aside">
+						<div class="open-panel"><i class="ti-angle-right"></i></div>
+						<div class="chat-left-inner bg-org"><ul class="chatonline style-none "></ul></div>
+					</div>
+					<!-- .chat-left-panel -->
+					<!-- .chat-right-panel -->
+					<div class="chat-right-aside">
+						<div class="chat-box">
+							<ul class="chat-list p-t-30"></ul>
+							<div class="row send-chat-box">
+								<div class="col-sm-12">
+									<textarea class="form-control chat-input-send" placeholder="Type your message" lang="en"></textarea>
+									<div class="custom-send">
+										<button type="button" class="btn btn-info btn-lg custom-send-button"><i class="fa fa-paper-plane fa-2x"></i> </button>
+									</div>
+								</div>
+							</div>
+						</div>
+					</div>
+					<!-- .chat-right-panel -->
+				</div>
+			</div>
+			`;
+			$('.append-menu').after(menuList);
+			$('.plugin-listing').append(htmlDOM);
+			pageLoad();
+			// Enable pusher logging - don't include this in production
+			//Pusher.logToConsole = true;
+			// Add API Key & cluster here to make the connection
+			var pusher = new Pusher(activeInfo.plugins.includes["CHAT-authKey-include"], {
+				cluster: activeInfo.plugins.includes["CHAT-cluster-include"],
+				encrypted: true
+			});
+			// Enter a unique channel you wish your users to be subscribed in.
+			var channel = pusher.subscribe('org_channel');
+			// bind the server event to get the response data and append it to the message div
+			channel.bind('my-event',
+				function(data) {
+					formatMessage(data);
+					$('.chat-list').append(formatMessage(data));
+					$('.custom-send').html('<button type="button" class="btn btn-info btn-lg custom-send-button"><i class="fa fa-paper-plane fa-2x"></i> </button>');
+					$(".chat-list").scrollTop($(".chat-list")[0].scrollHeight);
+					if($('#container-plugin-chat').hasClass('hidden')){
+						var chatSound =  new Audio(activeInfo.plugins.includes["CHAT-newMessageSound-include"]);
+						chatSound.play();
+						message(data.username,data.message,activeInfo.settings.notifications.position,"#FFF","success","20000");
+						$('.profile-image').addClass('animated loop-animation rubberBand');
+						$('.chat-counter').removeClass('hidden').html(parseInt($('.chat-counter').text()) + 1);
+					}
+				});
+			// check if the user is subscribed to the above channel
+			channel.bind('pusher:subscription_succeeded', function(members) {
+				organizrConsole('Plugin Function','Chat Websocket Connected!');
+				organizrConsole('Plugin Function','Connecting to Organizr Chat DB');
+				getMessagesAndUsers(activeInfo.settings.homepage.refresh["CHAT-userRefreshTimeout"], true);
+			});
+			/*jslint browser: true*/
+			/*global $, jQuery, alert*/
+			$(document).ready(function () {
+				"use strict";
+				$('.chat-left-inner > .chatonline').slimScroll({
+					height: '100%',
+					position: 'right',
+					size: "0px",
+					color: '#dcdcdc'
+				});
+				$('.chat-list').slimScroll({
+					height: '100%',
+					position: 'right',
+					size: "0px",
+					color: '#dcdcdc',
+					start: 'bottom',
+				});
+				$(".open-panel").on("click", function () {
+					$(".chat-left-aside").toggleClass("open-pnl");
+					$(".open-panel i").toggleClass("ti-angle-left");
+				});
+			});
+		}
+	}
 }
-$(document).on('click', '#CHAT-settings-button', function() {
-    ajaxloader(".content-wrap","in");
-	organizrAPI2('GET','api/v2/plugins/chat/settings').success(function(data) {
-        var response = data.response;
-        $('#CHAT-settings-items').html(buildFormGroup(response.data));
-    }).fail(function(xhr) {
-        console.error("Organizr Function: API Connection Failed");
-    });
-    ajaxloader();
-});
+
 //Chat functions!
 $(document).on('keypress', '.chat-input-send', function(ev) {
-    var keycode = (ev.keyCode ? ev.keyCode : ev.which);
-    if (keycode == '13') {
-        ev.preventDefault();
-        $('.custom-send-button').click();
-    }
+	var keycode = (ev.keyCode ? ev.keyCode : ev.which);
+	if (keycode == '13') {
+		ev.preventDefault();
+		$('.custom-send-button').click();
+	}
 });
 // Send the Message enter by User
 $('body').on('click', '.custom-send-button', function(e) {
-    e.preventDefault();
-    var message = $('.chat-input-send').val();
-    // Validate Name field
-    if (message !== '') {
-        organizrAPI2('POST','api/v2/plugins/chat/message',{ message : message }).success(function(data) {
-            // Nada yet
-        }).fail(function(xhr) {
-            console.error("Organizr Function: API Connection Failed");
-        });
-        // Clear the message input field
-        $('.chat-input-send').val('');
-        // Show a loading image while sending
-        $('.custom-send').html('<button type="button" class="btn btn-info btn-lg custom-send-button" disabled><i class="fa fa-spinner fa-pulse fa-2x"></i> </button>');
-    }
+	e.preventDefault();
+	var message = $('.chat-input-send').val();
+	// Validate Name field
+	if (message !== '') {
+		organizrAPI2('POST','api/v2/plugins/chat/message',{ message : message }).success(function(data) {
+			// Nada yet
+		}).fail(function(xhr) {
+			console.error("Organizr Function: API Connection Failed");
+		});
+		// Clear the message input field
+		$('.chat-input-send').val('');
+		// Show a loading image while sending
+		$('.custom-send').html('<button type="button" class="btn btn-info btn-lg custom-send-button" disabled><i class="fa fa-spinner fa-pulse fa-2x"></i> </button>');
+	}
 });
 function formatMessage(msg){
-    var className = 'odd';
-    if(msg.username == activeInfo.user.username){
-        if(activeInfo.user.username == 'Guest' && activeInfo.user.uid !== msg.uid){
-            className = '';
-        }
-    }else{
-        className = '';
-    }
-    return `
-        <li class="`+className+`">
-            <div class="chat-image"> <img alt="male" src="`+msg.gravatar+`"> </div>
-            <div class="chat-body">
-                <div class="chat-text">
-                    <h4>`+msg.username+`</h4>
-                    <p> `+msg.message+` </p> <b>`+moment.utc(msg.date, "YYYY-MM-DD hh:mm").local().format('LLL')+`</b> </div>
-            </div>
-        </li>
-    `;
+	var className = 'odd';
+	if(msg.username == activeInfo.user.username){
+		if(activeInfo.user.username == 'Guest' && activeInfo.user.uid !== msg.uid){
+			className = '';
+		}
+	}else{
+		className = '';
+	}
+	return `
+		<li class="`+className+`">
+			<div class="chat-image"> <img alt="male" src="`+msg.gravatar+`"> </div>
+			<div class="chat-body">
+				<div class="chat-text">
+					<h4>`+msg.username+`</h4>
+					<p> `+msg.message+` </p> <b>`+moment.utc(msg.date, "YYYY-MM-DD hh:mm").local().format('LLL')+`</b> </div>
+			</div>
+		</li>
+	`;
 }
 function formatUsers(array){
-    var users = {};
-    var userList = '';
-    array.reverse();
-    $.each(array, function (i, v){
-        if(!users.hasOwnProperty(v.username)){
-            users[v.username] = {
-                'last':v.date,
-                'gravatar':v.gravatar
-            }
-        }
-    });
-    $.each(users, function (i, v) {
-        userList += `
-            <li>
-                <a href="javascript:void(0)"><img src="`+v.gravatar+`" alt="user-img" class="img-circle"> <span>`+i+`<small class="text-success">`+moment.utc(v.last, "YYYY-MM-DD hh:mm[Z]").local().fromNow()+`</small></span></a>
-            </li>
-        `;
-    });
-    userList += '<li class="p-20"></li>';
-    return userList;
+	var users = {};
+	var userList = '';
+	array.reverse();
+	$.each(array, function (i, v){
+		if(!users.hasOwnProperty(v.username)){
+			users[v.username] = {
+				'last':v.date,
+				'gravatar':v.gravatar
+			}
+		}
+	});
+	$.each(users, function (i, v) {
+		userList += `
+			<li>
+				<a href="javascript:void(0)"><img src="`+v.gravatar+`" alt="user-img" class="img-circle"> <span>`+i+`<small class="text-success">`+moment.utc(v.last, "YYYY-MM-DD hh:mm[Z]").local().fromNow()+`</small></span></a>
+			</li>
+		`;
+	});
+	userList += '<li class="p-20"></li>';
+	return userList;
 }
 function chatEntry(){
-    $(".chat-list").scrollTop($(".chat-list")[0].scrollHeight);
-    $('.chat-input-send').focus();
-    $('.chat-counter').addClass('hidden').html('0');
+	$(".chat-list").scrollTop($(".chat-list")[0].scrollHeight);
+	$('.chat-input-send').focus();
+	$('.chat-counter').addClass('hidden').html('0');
 }
 function getMessagesAndUsers(timeout, initial = false){
-    var timeout = (typeof timeout !== 'undefined') ? timeout : activeInfo.settings.homepage.refresh["CHAT-userRefreshTimeout"];
-    organizrAPI2('GET','api/v2/plugins/chat/message').success(function(data) {
-        var response = data.response;
-        if(initial == true){
-            $.each(response.data, function (i, v){
-                $('.chat-list').append(formatMessage(v));
-            });
-        }
-        $('.chatonline').html(formatUsers(response.data));
-    }).fail(function(xhr) {
-        console.error("Organizr Function: API Connection Failed");
-    });
-    var timeoutTitle = 'ChatUserList';
-    if(typeof timeouts[timeoutTitle] !== 'undefined'){ clearTimeout(timeouts[timeoutTitle]); }
-    timeouts[timeoutTitle] = setTimeout(function(){ getMessagesAndUsers(timeout, false); }, timeout);
+	var timeout = (typeof timeout !== 'undefined') ? timeout : activeInfo.settings.homepage.refresh["CHAT-userRefreshTimeout"];
+	organizrAPI2('GET','api/v2/plugins/chat/message').success(function(data) {
+		var response = data.response;
+		if(initial == true){
+			$.each(response.data, function (i, v){
+				$('.chat-list').append(formatMessage(v));
+			});
+		}
+		$('.chatonline').html(formatUsers(response.data));
+	}).fail(function(xhr) {
+		console.error("Organizr Function: API Connection Failed");
+	});
+	var timeoutTitle = 'ChatUserList';
+	if(typeof timeouts[timeoutTitle] !== 'undefined'){ clearTimeout(timeouts[timeoutTitle]); }
+	timeouts[timeoutTitle] = setTimeout(function(){ getMessagesAndUsers(timeout, false); }, timeout);
 }
 $(document).on('click', '.profile-pic', function(e) {
-    $('.profile-image').removeClass('animated loop-animation rubberBand');
-});
+	$('.profile-image').removeClass('animated loop-animation rubberBand');
+});

+ 110 - 0
api/plugins/js/healthChecks-settings.js

@@ -0,0 +1,110 @@
+/* HEALTHCHECKS.IO JS FILE */
+
+// FUNCTIONS
+
+// EVENTS and LISTENERS
+
+// CHANGE CUSTOMIZE Options
+//
+$(document).on('click', '#HEALTHCHECKS-settings-button', function() {
+    ajaxloader(".content-wrap","in");
+    organizrAPI2('GET','api/v2/plugins/healthchecks/settings').success(function(data) {
+        var response = data.response;
+        $('#HEALTHCHECKS-settings-items').html(buildFormGroup(response.data));
+        var elAddButtonStart = $('#HEALTHCHECKS-settings-page [id*="Services"] .row.start');
+        var testone = $('#HEALTHCHECKS-settings-page [id*="Services"] .row.m-b-40').first('span')
+        var testtwo = $('#HEALTHCHECKS-settings-page [id*="Services"] .row.m-b-40 span')
+        $(elAddButtonStart).after('<div class="row"><button type="button" class="btn btn-info pull-right m-r-20 addNewHCService" ><i class="fa fa-plus"></i> Add New Service</button></div>');
+        $.each(testtwo, function(key,val) {
+            var el = $(val);
+            var text = el.text();
+            if(text === 'Service Name'){
+                $(this).after('&nbsp;<div class="pull-right text-danger removeHCService mouse"><i class="fa fa-close text-danger"></i></div>');
+            }
+        })
+
+    }).fail(function(xhr) {
+        console.error("Organizr Function: API Connection Failed");
+    });
+    ajaxloader();
+});
+$(document).on('click', '.addNewHCService', function() {
+    var lastEl = $('#HEALTHCHECKS-settings-page [name*="HEALTHCHECKS-all-items"]').last().attr('name');
+    var newNum = 0;
+    if(typeof lastEl !== 'undefined'){
+        lastEl = Number($('#HEALTHCHECKS-settings-page [name*="HEALTHCHECKS-all-items"]').last().attr('name').replace(/\D/g, ''));
+        newNum = lastEl + 1;
+    }
+    var copyEl = '' +
+        '<div class="row m-b-40">\n' +
+        '\t<!-- INPUT BOX  Yes Multiple -->\n' +
+        '\t<div class="col-md-6 p-b-10">\n' +
+        '\t\t<div class="form-group">\n' +
+        '\t\t\t<label class="control-label col-md-12"><span lang="en">Service Name</span>&nbsp;<div class="pull-right text-danger removeHCService mouse"><i class="fa fa-close text-danger"></i></div></label>\n' +
+        '\t\t\t<div class="col-md-12"> <input data-changed="false" lang="en" type="text" class="form-control" value="" name="HEALTHCHECKS-all-items[999999].name" data-type="input" data-label="Service Name" autocomplete="new-password"> </div> <!-- end div -->\n' +
+        '\t\t</div>\n' +
+        '\t</div>\n' +
+        '\t<!--/ INPUT BOX -->\n' +
+        '\n' +
+        '\t<!-- INPUT BOX  Yes Multiple -->\n' +
+        '\t<div class="col-md-6 p-b-10">\n' +
+        '\t\t<div class="form-group">\n' +
+        '\t\t\t<label class="control-label col-md-12"><span lang="en">UUID</span></label>\n' +
+        '\t\t\t<div class="col-md-12"> <input data-changed="false" lang="en" type="text" class="form-control" value="" name="HEALTHCHECKS-all-items[999999].uuid" data-type="input" data-label="UUID" autocomplete="new-password"> </div> <!-- end div -->\n' +
+        '\t\t</div>\n' +
+        '\t</div>\n' +
+        '\t<!--/ INPUT BOX -->\n' +
+        '\n' +
+        '\t<!-- INPUT BOX  Yes Multiple -->\n' +
+        '\t<div class="col-md-6 p-b-10">\n' +
+        '\t\t<div class="form-group">\n' +
+        '\t\t\t<label class="control-label col-md-12"><span lang="en">External URL</span></label>\n' +
+        '\t\t\t<div class="col-md-12"> <input data-changed="false" lang="en" type="text" class="form-control" value="" name="HEALTHCHECKS-all-items[999999].external" data-type="input" data-label="External URL" autocomplete="new-password"> </div> <!-- end div -->\n' +
+        '\t\t</div>\n' +
+        '\t</div>\n' +
+        '\t<!--/ INPUT BOX -->\n' +
+        '\n' +
+        '\t<!-- INPUT BOX  Yes Multiple -->\n' +
+        '\t<div class="col-md-6 p-b-10">\n' +
+        '\t\t<div class="form-group">\n' +
+        '\t\t\t<label class="control-label col-md-12"><span lang="en">Internal URL</span></label>\n' +
+        '\t\t\t<div class="col-md-12"> <input data-changed="false" lang="en" type="text" class="form-control" value="" name="HEALTHCHECKS-all-items[999999].internal" data-type="input" data-label="Internal URL" autocomplete="new-password"> </div> <!-- end div -->\n' +
+        '\t\t</div>\n' +
+        '\t</div>\n' +
+        '\t<!--/ INPUT BOX -->\n' +
+        '\n' +
+        '\t<!-- INPUT BOX  Yes Multiple -->\n' +
+        '\t<div class="col-md-6 p-b-10">\n' +
+        '\t\t<div class="form-group">\n' +
+        '\t\t\t<label class="control-label col-md-12"><span lang="en">Enabled</span></label>\n' +
+        '\t\t\t<div class="col-md-12"> <input data-changed="false" type="checkbox" class="js-switch" data-size="small" data-color="#99d683" data-secondary-color="#f96262" name="HEALTHCHECKS-all-items[999999].enabled" value="" checked="" data-type="switch" data-label="Enabled"><input data-changed="false" type="hidden" name="HEALTHCHECKS-all-items[999999].enabled" value=""> </div> <!-- end div -->\n' +
+        '\t\t</div>\n' +
+        '\t</div>\n' +
+        '\t<!--/ INPUT BOX -->\n' +
+        '</div>'
+//smallLabel+'<input data-changed="false" type="checkbox" class="js-switch'+extraClass+'" data-size="small" data-color="#99d683" data-secondary-color="#f96262"'+name+value+tof(item.value,'c')+id+disabled+type+label+attr+' /><input data-changed="false" type="hidden"'+name+'value="false">';
+    var elAddButtonStart = $('#HEALTHCHECKS-settings-page [id*="Services"] .row.start');
+    var copiedEl = $(copyEl).clone();
+    copiedEl.find("input").each(function() {
+        var currentName = $(this).attr("name");
+        var newName = currentName.replace('999999', newNum);
+        $(this).attr("name", newName);
+        $(this).attr("value", "");
+    });
+    $(copiedEl).appendTo(elAddButtonStart);
+    $(function () {
+        // Switchery
+        var elems = Array.prototype.slice.call(document.querySelectorAll('.js-switch'));
+        $('.js-switch').each(function() {
+            if ($(this).attr('data-switchery') !== 'true'){
+                new Switchery($(this)[0], $(this).data());
+            }
+        });
+    });
+
+});
+
+$(document).on('click', '.removeHCService', function() {
+    $(this).closest('.row').remove();
+    $('#HEALTHCHECKS-settings-page-save').removeClass('hidden');
+});

+ 1 - 110
api/plugins/js/healthChecks.js

@@ -1,110 +1 @@
-/* HEALTHCHECKS.IO JS FILE */
-
-// FUNCTIONS
-
-// EVENTS and LISTENERS
-
-// CHANGE CUSTOMIZE Options
-//
-$(document).on('click', '#HEALTHCHECKS-settings-button', function() {
-    ajaxloader(".content-wrap","in");
-    organizrAPI2('GET','api/v2/plugins/healthchecks/settings').success(function(data) {
-        var response = data.response;
-        $('#HEALTHCHECKS-settings-items').html(buildFormGroup(response.data));
-        var elAddButtonStart = $('#HEALTHCHECKS-settings-page [id*="Services"] .row.start');
-        var testone = $('#HEALTHCHECKS-settings-page [id*="Services"] .row.m-b-40').first('span')
-        var testtwo = $('#HEALTHCHECKS-settings-page [id*="Services"] .row.m-b-40 span')
-        $(elAddButtonStart).after('<div class="row"><button type="button" class="btn btn-info pull-right m-r-20 addNewHCService" ><i class="fa fa-plus"></i> Add New Service</button></div>');
-        $.each(testtwo, function(key,val) {
-            var el = $(val);
-            var text = el.text();
-            if(text === 'Service Name'){
-                $(this).after('&nbsp;<div class="pull-right text-danger removeHCService mouse"><i class="fa fa-close text-danger"></i></div>');
-            }
-        })
-
-    }).fail(function(xhr) {
-        console.error("Organizr Function: API Connection Failed");
-    });
-    ajaxloader();
-});
-$(document).on('click', '.addNewHCService', function() {
-    var lastEl = $('#HEALTHCHECKS-settings-page [name*="HEALTHCHECKS-all-items"]').last().attr('name');
-    var newNum = 0;
-    if(typeof lastEl !== 'undefined'){
-        lastEl = Number($('#HEALTHCHECKS-settings-page [name*="HEALTHCHECKS-all-items"]').last().attr('name').replace(/\D/g, ''));
-        newNum = lastEl + 1;
-    }
-    var copyEl = '' +
-        '<div class="row m-b-40">\n' +
-        '\t<!-- INPUT BOX  Yes Multiple -->\n' +
-        '\t<div class="col-md-6 p-b-10">\n' +
-        '\t\t<div class="form-group">\n' +
-        '\t\t\t<label class="control-label col-md-12"><span lang="en">Service Name</span>&nbsp;<div class="pull-right text-danger removeHCService mouse"><i class="fa fa-close text-danger"></i></div></label>\n' +
-        '\t\t\t<div class="col-md-12"> <input data-changed="false" lang="en" type="text" class="form-control" value="" name="HEALTHCHECKS-all-items[999999].name" data-type="input" data-label="Service Name" autocomplete="new-password"> </div> <!-- end div -->\n' +
-        '\t\t</div>\n' +
-        '\t</div>\n' +
-        '\t<!--/ INPUT BOX -->\n' +
-        '\n' +
-        '\t<!-- INPUT BOX  Yes Multiple -->\n' +
-        '\t<div class="col-md-6 p-b-10">\n' +
-        '\t\t<div class="form-group">\n' +
-        '\t\t\t<label class="control-label col-md-12"><span lang="en">UUID</span></label>\n' +
-        '\t\t\t<div class="col-md-12"> <input data-changed="false" lang="en" type="text" class="form-control" value="" name="HEALTHCHECKS-all-items[999999].uuid" data-type="input" data-label="UUID" autocomplete="new-password"> </div> <!-- end div -->\n' +
-        '\t\t</div>\n' +
-        '\t</div>\n' +
-        '\t<!--/ INPUT BOX -->\n' +
-        '\n' +
-        '\t<!-- INPUT BOX  Yes Multiple -->\n' +
-        '\t<div class="col-md-6 p-b-10">\n' +
-        '\t\t<div class="form-group">\n' +
-        '\t\t\t<label class="control-label col-md-12"><span lang="en">External URL</span></label>\n' +
-        '\t\t\t<div class="col-md-12"> <input data-changed="false" lang="en" type="text" class="form-control" value="" name="HEALTHCHECKS-all-items[999999].external" data-type="input" data-label="External URL" autocomplete="new-password"> </div> <!-- end div -->\n' +
-        '\t\t</div>\n' +
-        '\t</div>\n' +
-        '\t<!--/ INPUT BOX -->\n' +
-        '\n' +
-        '\t<!-- INPUT BOX  Yes Multiple -->\n' +
-        '\t<div class="col-md-6 p-b-10">\n' +
-        '\t\t<div class="form-group">\n' +
-        '\t\t\t<label class="control-label col-md-12"><span lang="en">Internal URL</span></label>\n' +
-        '\t\t\t<div class="col-md-12"> <input data-changed="false" lang="en" type="text" class="form-control" value="" name="HEALTHCHECKS-all-items[999999].internal" data-type="input" data-label="Internal URL" autocomplete="new-password"> </div> <!-- end div -->\n' +
-        '\t\t</div>\n' +
-        '\t</div>\n' +
-        '\t<!--/ INPUT BOX -->\n' +
-        '\n' +
-        '\t<!-- INPUT BOX  Yes Multiple -->\n' +
-        '\t<div class="col-md-6 p-b-10">\n' +
-        '\t\t<div class="form-group">\n' +
-        '\t\t\t<label class="control-label col-md-12"><span lang="en">Enabled</span></label>\n' +
-        '\t\t\t<div class="col-md-12"> <input data-changed="false" type="checkbox" class="js-switch" data-size="small" data-color="#99d683" data-secondary-color="#f96262" name="HEALTHCHECKS-all-items[999999].enabled" value="" checked="" data-type="switch" data-label="Enabled"><input data-changed="false" type="hidden" name="HEALTHCHECKS-all-items[999999].enabled" value=""> </div> <!-- end div -->\n' +
-        '\t\t</div>\n' +
-        '\t</div>\n' +
-        '\t<!--/ INPUT BOX -->\n' +
-        '</div>'
-//smallLabel+'<input data-changed="false" type="checkbox" class="js-switch'+extraClass+'" data-size="small" data-color="#99d683" data-secondary-color="#f96262"'+name+value+tof(item.value,'c')+id+disabled+type+label+attr+' /><input data-changed="false" type="hidden"'+name+'value="false">';
-    var elAddButtonStart = $('#HEALTHCHECKS-settings-page [id*="Services"] .row.start');
-    var copiedEl = $(copyEl).clone();
-    copiedEl.find("input").each(function() {
-        var currentName = $(this).attr("name");
-        var newName = currentName.replace('999999', newNum);
-        $(this).attr("name", newName);
-        $(this).attr("value", "");
-    });
-    $(copiedEl).appendTo(elAddButtonStart);
-    $(function () {
-        // Switchery
-        var elems = Array.prototype.slice.call(document.querySelectorAll('.js-switch'));
-        $('.js-switch').each(function() {
-            if ($(this).attr('data-switchery') !== 'true'){
-                new Switchery($(this)[0], $(this).data());
-            }
-        });
-    });
-
-});
-
-$(document).on('click', '.removeHCService', function() {
-    $(this).closest('.row').remove();
-    $('#HEALTHCHECKS-settings-page-save').removeClass('hidden');
-});
+/* HEALTHCHECKS.IO JS FILE IS NO LONGER NEEDED OR USED */

+ 398 - 422
api/plugins/js/invites.js

@@ -1,231 +1,223 @@
 /* INVITES JS FILE */
+$('body').arrive('#activeInfo', {onceOnly: true}, function() {
+	inviteLaunch();
+});
 // FUNCTIONS
-inviteLaunch()
 function inviteLaunch(){
-    if(typeof activeInfo == 'undefined'){
-        setTimeout(function () {
-            inviteLaunch();
-        }, 1000);
-    }else{
-        var menuList = '';
-    	var htmlDOM = `
-    	<div id="invite-area" class="white-popup mfp-with-anim mfp-hide">
-    		<div class="col-md-10 col-md-offset-1">
-    			<div class="invite-div"></div>
-    		</div>
-    	</div>
-    	`;
-        if(activeInfo.plugins["INVITES-enabled"] == true){
-            if (activeInfo.user.loggedin === true && activeInfo.user.groupID <= 1) {
-                menuList = `<li><a class="inline-popups inviteModal" href="#invite-area" data-effect="mfp-zoom-out"><i class="fa fa-ticket fa-fw"></i> <span lang="en">Manage Invites</span></a></li>`;
-                htmlDOM += `
-            	<div id="new-invite-area" class="white-popup mfp-with-anim mfp-hide">
-            		<div class="col-md-10 col-md-offset-1">
-                        <div class="col-md-12">
-                            <div class="panel panel-info m-b-0">
-                                <div class="panel-heading" lang="en">New Invite</div>
-                                <div class="panel-wrapper collapse in" aria-expanded="true">
-                                    <div class="panel-body">
-
-                                        <form id="new-invite-form">
-                                            <fieldset style="border:0;">
-                                            <div class="form-group">
-                                                <label class="control-label" for="new-invite-form-inputUsername" lang="en">Name or Username</label>
-                                                <input type="text" class="form-control" id="new-invite-form-inputUsername" name="username" required="" autofocus="">
-                                            </div>
-                                            <div class="form-group">
-                                                <label class="control-label" for="new-invite-form-inputEmail" lang="en">Email</label>
-                                                <input type="text" class="form-control" id="new-invite-form-inputEmail" name="email" required="" autofocus="">
-                                            </div>
-                                            </fieldset>
-                                            <button class="btn btn-sm btn-info btn-rounded waves-effect waves-light pull-right row b-none" onclick="createNewInvite();" type="button"><span class="btn-label"><i class="fa fa-plus"></i></span><span lang="en">Create/Send Invite</span></button>
-                                            <div class="clearfix"></div>
-                                        </form>
-
-
-
-                                    </div>
-                                </div>
-                            </div>
-                        </div>
-                        <div class="clearfix"></div>
-            		</div>
-            	</div>`;
-            }else if (activeInfo.user.loggedin === false){
-                menuList = `<li><a class="inline-popups inviteModal" href="#invite-area" data-effect="mfp-zoom-out"><i class="fa fa-ticket fa-fw"></i> <span lang="en">Use Invite Code</span></a></li>`;
-            }
-            $('.append-menu').after(menuList);
-            $('.organizr-area').after(htmlDOM);
-            pageLoad();
-            getInvite();
-        }
-    }
+	var menuList = '';
+	var htmlDOM = `
+	<div id="invite-area" class="white-popup mfp-with-anim mfp-hide">
+		<div class="col-md-10 col-md-offset-1">
+			<div class="invite-div"></div>
+		</div>
+	</div>
+	`;
+	if(activeInfo.plugins["INVITES-enabled"] == true){
+		if (activeInfo.user.loggedin === true && activeInfo.user.groupID <= 1) {
+			menuList = `<li><a class="inline-popups inviteModal" href="#invite-area" data-effect="mfp-zoom-out"><i class="fa fa-ticket fa-fw"></i> <span lang="en">Manage Invites</span></a></li>`;
+			htmlDOM += `
+			<div id="new-invite-area" class="white-popup mfp-with-anim mfp-hide">
+				<div class="col-md-10 col-md-offset-1">
+					<div class="col-md-12">
+						<div class="panel panel-info m-b-0">
+							<div class="panel-heading" lang="en">New Invite</div>
+							<div class="panel-wrapper collapse in" aria-expanded="true">
+								<div class="panel-body">
+									<form id="new-invite-form">
+										<fieldset style="border:0;">
+										<div class="form-group">
+											<label class="control-label" for="new-invite-form-inputUsername" lang="en">Name or Username</label>
+											<input type="text" class="form-control" id="new-invite-form-inputUsername" name="username" required="" autofocus="">
+										</div>
+										<div class="form-group">
+											<label class="control-label" for="new-invite-form-inputEmail" lang="en">Email</label>
+											<input type="text" class="form-control" id="new-invite-form-inputEmail" name="email" required="" autofocus="">
+										</div>
+										</fieldset>
+										<button class="btn btn-sm btn-info btn-rounded waves-effect waves-light pull-right row b-none" onclick="createNewInvite();" type="button"><span class="btn-label"><i class="fa fa-plus"></i></span><span lang="en">Create/Send Invite</span></button>
+										<div class="clearfix"></div>
+									</form>
+								</div>
+							</div>
+						</div>
+					</div>
+					<div class="clearfix"></div>
+				</div>
+			</div>`;
+		}else if (activeInfo.user.loggedin === false){
+			menuList = `<li><a class="inline-popups inviteModal" href="#invite-area" data-effect="mfp-zoom-out"><i class="fa fa-ticket fa-fw"></i> <span lang="en">Use Invite Code</span></a></li>`;
+		}
+		$('.append-menu').after(menuList);
+		$('.organizr-area').after(htmlDOM);
+		pageLoad();
+		getInvite();
+	}
 }
 function joinPlex(){
-    var username = $('#invitePlexJoinUsername');
-    var email = $('#invitePlexJoinEmail');
-    var password = $('#invitePlexJoinPassword');
-    if(username.val() == ''){
-        username.focus();
-        message('Invite Error',' Please Enter Username',activeInfo.settings.notifications.position,'#FFF','warning','5000');
-    }else if(email.val() == ''){
-        email.focus();
-        message('Invite Error',' Please Enter Email',activeInfo.settings.notifications.position,'#FFF','warning','5000');
-    }else if(password.val() == ''){
-        password.focus();
-        message('Invite Error',' Please Enter Password',activeInfo.settings.notifications.position,'#FFF','warning','5000');
-    }
-    if(email.val() !== '' && username.val() !== '' && password.val() !== ''){
-        organizrAPI2('POST','api/v2/plex/register',{username:username.val(), email:email.val(), password:password.val()}).success(function(data) {
-    		var response = data.response;
-            if(response.result === 'success'){
-                $('.invite-step-3-plex-no').toggleClass('hidden');
-                $('.invite-step-3-plex-yes').toggleClass('hidden');
-                message('Invite Function',' User Created',activeInfo.settings.notifications.position,'#FFF','success','5000');
-                $('#inviteUsernameInvite').val(username.val());
-                hasPlexUsername();
-            }else{
-                message('Invite Error',' '+response.message,activeInfo.settings.notifications.position,'#FFF','warning','5000');
-            }
-    	}).fail(function(xhr) {
-    		console.error("Organizr Function: API Connection Failed");
-    	});
-    }
+	var username = $('#invitePlexJoinUsername');
+	var email = $('#invitePlexJoinEmail');
+	var password = $('#invitePlexJoinPassword');
+	if(username.val() == ''){
+		username.focus();
+		message('Invite Error',' Please Enter Username',activeInfo.settings.notifications.position,'#FFF','warning','5000');
+	}else if(email.val() == ''){
+		email.focus();
+		message('Invite Error',' Please Enter Email',activeInfo.settings.notifications.position,'#FFF','warning','5000');
+	}else if(password.val() == ''){
+		password.focus();
+		message('Invite Error',' Please Enter Password',activeInfo.settings.notifications.position,'#FFF','warning','5000');
+	}
+	if(email.val() !== '' && username.val() !== '' && password.val() !== ''){
+		organizrAPI2('POST','api/v2/plex/register',{username:username.val(), email:email.val(), password:password.val()}).success(function(data) {
+			var response = data.response;
+			if(response.result === 'success'){
+				$('.invite-step-3-plex-no').toggleClass('hidden');
+				$('.invite-step-3-plex-yes').toggleClass('hidden');
+				message('Invite Function',' User Created',activeInfo.settings.notifications.position,'#FFF','success','5000');
+				$('#inviteUsernameInvite').val(username.val());
+				hasPlexUsername();
+			}else{
+				message('Invite Error',' '+response.message,activeInfo.settings.notifications.position,'#FFF','warning','5000');
+			}
+		}).fail(function(xhr) {
+			OrganizrApiError(xhr, 'Plex Signup Error');
+		});
+	}
 }
 
 function joinEmby(){
-    var username = $('#inviteEmbyJoinUsername');
-    var email = $('#inviteEmbyJoinEmail');
-    var password = $('#inviteEmbyJoinPassword');
-    if(username.val() == ''){
-        username.focus();
-        message('Invite Error',' Please Enter Username',activeInfo.settings.notifications.position,'#FFF','warning','5000');
-    }else if(email.val() == ''){
-        email.focus();
-        message('Invite Error',' Please Enter Email',activeInfo.settings.notifications.position,'#FFF','warning','5000');
-    }else if(password.val() == ''){
-        password.focus();
-        message('Invite Error',' Please Enter Password',activeInfo.settings.notifications.position,'#FFF','warning','5000');
-    }
-    if(email.val() !== '' && username.val() !== '' && password.val() !== ''){
-        organizrAPI2('POST','api/v2/emby/register',{username:username.val(), email:email.val(), password:password.val()}).success(function(data) {
-    		var response = data.response;
-            if(response.result === 'success'){
-                $('.invite-step-3-emby-no').toggleClass('hidden');
-                $('.invite-step-3-emby-yes').toggleClass('hidden');
-                message('Invite Function',' User Created',activeInfo.settings.notifications.position,'#FFF','success','5000');
-                $('#inviteUsernameInviteEmby').val(username.val());
-                hasEmbyUsername();
-            }else{
-                message('Invite Error',' '+response.message,activeInfo.settings.notifications.position,'#FFF','warning','5000');
-            }
-    	}).fail(function(xhr) {
-    		console.error("Organizr Function: API Connection Failed");
-    	});
-    }
+	var username = $('#inviteEmbyJoinUsername');
+	var email = $('#inviteEmbyJoinEmail');
+	var password = $('#inviteEmbyJoinPassword');
+	if(username.val() == ''){
+		username.focus();
+		message('Invite Error',' Please Enter Username',activeInfo.settings.notifications.position,'#FFF','warning','5000');
+	}else if(email.val() == ''){
+		email.focus();
+		message('Invite Error',' Please Enter Email',activeInfo.settings.notifications.position,'#FFF','warning','5000');
+	}else if(password.val() == ''){
+		password.focus();
+		message('Invite Error',' Please Enter Password',activeInfo.settings.notifications.position,'#FFF','warning','5000');
+	}
+	if(email.val() !== '' && username.val() !== '' && password.val() !== ''){
+		organizrAPI2('POST','api/v2/emby/register',{username:username.val(), email:email.val(), password:password.val()}).success(function(data) {
+			var response = data.response;
+			if(response.result === 'success'){
+				$('.invite-step-3-emby-no').toggleClass('hidden');
+				$('.invite-step-3-emby-yes').toggleClass('hidden');
+				message('Invite Function',' User Created',activeInfo.settings.notifications.position,'#FFF','success','5000');
+				$('#inviteUsernameInviteEmby').val(username.val());
+				hasEmbyUsername();
+			}else{
+				message('Invite Error',' '+response.message,activeInfo.settings.notifications.position,'#FFF','warning','5000');
+			}
+		}).fail(function(xhr) {
+			OrganizrApiError(xhr, 'Emby Signup Error');
+		});
+	}
 }
 
 function inviteHasAccount(type,value){
-    switch (type) {
-        case 'plex':
-            if(value){
-                $('.invite-step-2').toggleClass('hidden');
-                $('.invite-step-3-plex-yes').toggleClass('hidden');
-            }else{
-                $('.invite-step-2').toggleClass('hidden');
-                $('.invite-step-3-plex-no').toggleClass('hidden');
-            }
-            break;
-        case 'emby' :
-          if(value){
-            $('.invite-step-2').toggleClass('hidden');
-            $('.invite-step-3-emby-yes').toggleClass('hidden');
-          }else{
-            $('.invite-step-2').toggleClass('hidden');
-            $('.invite-step-3-emby-no').toggleClass('hidden');
-          }
-          break;
-        default:
-        alert(type+' is not set up yet');
-    }
+	switch (type) {
+		case 'plex':
+			if(value){
+				$('.invite-step-2').toggleClass('hidden');
+				$('.invite-step-3-plex-yes').toggleClass('hidden');
+			}else{
+				$('.invite-step-2').toggleClass('hidden');
+				$('.invite-step-3-plex-no').toggleClass('hidden');
+			}
+			break;
+		case 'emby' :
+		  if(value){
+			$('.invite-step-2').toggleClass('hidden');
+			$('.invite-step-3-emby-yes').toggleClass('hidden');
+		  }else{
+			$('.invite-step-2').toggleClass('hidden');
+			$('.invite-step-3-emby-no').toggleClass('hidden');
+		  }
+		  break;
+		default:
+		alert(type+' is not set up yet');
+	}
 }
 function hasPlexUsername(){
-    var code = $('#inviteCodeInput').val().toUpperCase();
-    var username = $('#inviteUsernameInvite');
-    if(username.val() == ''){
-        username.focus();
-        message('Invite Error',' Please Enter Username',activeInfo.settings.notifications.position,'#FFF','warning','5000');
-    }else{
-        var post = {
-            usedby:username.val()
-        };
-        ajaxloader(".content-wrap","in");
-        organizrAPI2('POST','api/v2/plugins/invites/' + code,post).success(function(data) {
-            var response = data.response;
-            if(response.result === 'success'){
-                $('.invite-step-3-plex-yes').toggleClass('hidden');
-                $('.invite-step-4-plex-accept').toggleClass('hidden');
-                if(local('get', 'invite')){
-            		local('remove', 'invite');
-            	}
-            }else{
-                message('Invite Error',response.message,activeInfo.settings.notifications.position,'#FFF','warning','5000');
-            }
-            ajaxloader();;
-        }).fail(function(xhr) {
-            console.error("Organizr Function: API Connection Failed");
-            ajaxloader();
-        });
-    }
+	var code = $('#inviteCodeInput').val().toUpperCase();
+	var username = $('#inviteUsernameInvite');
+	if(username.val() == ''){
+		username.focus();
+		message('Invite Error',' Please Enter Username',activeInfo.settings.notifications.position,'#FFF','warning','5000');
+	}else{
+		var post = {
+			usedby:username.val()
+		};
+		ajaxloader(".content-wrap","in");
+		organizrAPI2('POST','api/v2/plugins/invites/' + code,post).success(function(data) {
+			var response = data.response;
+			if(response.result === 'success'){
+				$('.invite-step-3-plex-yes').toggleClass('hidden');
+				$('.invite-step-4-plex-accept').toggleClass('hidden');
+				if(local('get', 'invite')){
+					local('remove', 'invite');
+				}
+			}else{
+				message('Invite Error',response.message,activeInfo.settings.notifications.position,'#FFF','warning','5000');
+			}
+			ajaxloader();;
+		}).fail(function(xhr) {
+			OrganizrApiError(xhr);
+			ajaxloader();
+		});
+	}
 }
 function hasEmbyUsername(){
-    var code = $('#inviteCodeInput').val().toUpperCase();
-    var username = $('#inviteUsernameInviteEmby');
-    if(username.val() == ''){
-        username.focus();
-        message('Invite Error',' Please Enter Username',activeInfo.settings.notifications.position,'#FFF','warning','5000');
-    }else{
-        var post = {
-            usedby:username.val()
-        };
-        ajaxloader(".content-wrap","in");
-        organizrAPI2('POST','api/v2/plugins/invites/' + code,post).success(function(data) {
-	        var response = data.response;
-	        if(response.result === 'success'){
-                $('.invite-step-3-emby-yes').toggleClass('hidden');
-                $('.invite-step-4-emby-accept').toggleClass('hidden');
-                if(local('get', 'invite')){
-            		local('remove', 'invite');
-            	}
-            }else{
-                message('Invite Error',response.message,activeInfo.settings.notifications.position,'#FFF','warning','5000');
-            }
-            ajaxloader();;
-        }).fail(function(xhr) {
-            console.error("Organizr Function: API Connection Failed");
-            ajaxloader();
-        });
-    }
+	var code = $('#inviteCodeInput').val().toUpperCase();
+	var username = $('#inviteUsernameInviteEmby');
+	if(username.val() == ''){
+		username.focus();
+		message('Invite Error',' Please Enter Username',activeInfo.settings.notifications.position,'#FFF','warning','5000');
+	}else{
+		var post = {
+			usedby:username.val()
+		};
+		ajaxloader(".content-wrap","in");
+		organizrAPI2('POST','api/v2/plugins/invites/' + code,post).success(function(data) {
+			var response = data.response;
+			if(response.result === 'success'){
+				$('.invite-step-3-emby-yes').toggleClass('hidden');
+				$('.invite-step-4-emby-accept').toggleClass('hidden');
+				if(local('get', 'invite')){
+					local('remove', 'invite');
+				}
+			}else{
+				message('Invite Error',response.message,activeInfo.settings.notifications.position,'#FFF','warning','5000');
+			}
+			ajaxloader();;
+		}).fail(function(xhr) {
+			OrganizrApiError(xhr);
+			ajaxloader();
+		});
+	}
 }
 function verifyInvite(){
-    var code = $('#inviteCodeInput').val().toUpperCase();
-    ajaxloader(".content-wrap","in");
-    organizrAPI2('GET','api/v2/plugins/invites/'+code).success(function(data) {
-        var response = data.response;
-        if(response.result === 'success'){
-            $('.invite-step-1').toggleClass('hidden');
-            $('.invite-step-2').toggleClass('hidden');
-        }else{
-            message('Invite Error',response.message,activeInfo.settings.notifications.position,'#FFF','warning','5000');
-        }
-        if(local('get', 'invite')){
-            local('remove', 'invite');
-        }
-        ajaxloader();;
-    }).fail(function(xhr) {
-        console.error("Organizr Function: API Connection Failed");
-        ajaxloader();
-    });
+	var code = $('#inviteCodeInput').val().toUpperCase();
+	ajaxloader(".content-wrap","in");
+	organizrAPI2('GET','api/v2/plugins/invites/'+code).success(function(data) {
+		var response = data.response;
+		if(response.result === 'success'){
+			$('.invite-step-1').toggleClass('hidden');
+			$('.invite-step-2').toggleClass('hidden');
+		}else{
+			message('Invite Error',response.message,activeInfo.settings.notifications.position,'#FFF','warning','5000');
+		}
+		if(local('get', 'invite')){
+			local('remove', 'invite');
+		}
+		ajaxloader();;
+	}).fail(function(xhr) {
+		OrganizrApiError(xhr);
+		ajaxloader();
+	});
 }
 function getInvite(invite=null){
 	if(invite){
@@ -237,232 +229,216 @@ function getInvite(invite=null){
 	if(local('get', 'invite')){
 		//show error page
 		$('.inviteModal').trigger('click');
-        $('#inviteCodeInput').val(local('get', 'invite'));
+		$('#inviteCodeInput').val(local('get', 'invite'));
 		window.history.pushState({}, document.title, "./" );
-        local('remove', 'invite');
+		local('remove', 'invite');
 	}
 
 }
 function createNewInvite(){
-    var username = $('#new-invite-form-inputUsername');
-    var email = $('#new-invite-form-inputEmail');
-    if(username.val() == ''){
-        username.focus();
-        message('Invite Error',' Please Enter Username',activeInfo.settings.notifications.position,'#FFF','warning','5000');
-    }else if(email.val() == ''){
-        email.focus();
-        message('Invite Error',' Please Enter Email',activeInfo.settings.notifications.position,'#FFF','warning','5000');
-    }
+	var username = $('#new-invite-form-inputUsername');
+	var email = $('#new-invite-form-inputEmail');
+	if(username.val() == ''){
+		username.focus();
+		message('Invite Error',' Please Enter Username',activeInfo.settings.notifications.position,'#FFF','warning','5000');
+	}else if(email.val() == ''){
+		email.focus();
+		message('Invite Error',' Please Enter Email',activeInfo.settings.notifications.position,'#FFF','warning','5000');
+	}
 
-    if(email.val() !== '' && username.val() !== ''){
-        var post = {
-            code:createRandomString(6).toUpperCase(),
-            email:email.val(),
-            username:username.val(),
-        };
-        ajaxloader(".content-wrap","in");
-        organizrAPI2('POST','api/v2/plugins/invites',post).success(function(data) {
-            var response = data.response;
-            $.magnificPopup.close();
-            ajaxloader();
-            message('Invite',' Invite Created',activeInfo.settings.notifications.position,'#FFF','success','5000');
-        }).fail(function(xhr) {
-            console.error("Organizr Function: API Connection Failed");
-            ajaxloader();
-            message('Invite Error',' An Error Occured',activeInfo.settings.notifications.position,'#FFF','error','5000');
-        });
-    }
+	if(email.val() !== '' && username.val() !== ''){
+		var post = {
+			code:createRandomString(6).toUpperCase(),
+			email:email.val(),
+			username:username.val(),
+		};
+		ajaxloader(".content-wrap","in");
+		organizrAPI2('POST','api/v2/plugins/invites',post).success(function(data) {
+			var response = data.response;
+			$.magnificPopup.close();
+			ajaxloader();
+			message('Invite',' Invite Created',activeInfo.settings.notifications.position,'#FFF','success','5000');
+		}).fail(function(xhr) {
+			OrganizrApiError(xhr);
+			ajaxloader();
+			message('Invite Error',' An Error Occured',activeInfo.settings.notifications.position,'#FFF','error','5000');
+		});
+	}
 
 }
 function deleteInvite(code, id){
-    ajaxloader(".content-wrap","in");
-    organizrAPI2('DELETE','api/v2/plugins/invites/' + code).success(function(data) {
-        var response = data.response;
-        $('#inviteItem-'+id).remove();
-        //$.magnificPopup.close();
-        ajaxloader();
-        message('Invite',' Invite Deleted',activeInfo.settings.notifications.position,'#FFF','success','5000');
-    }).fail(function(xhr) {
-        console.error("Organizr Function: API Connection Failed");
-        ajaxloader();
-        message('Invite Error',' An Error Occured',activeInfo.settings.notifications.position,'#FFF','error','5000');
-    });
+	ajaxloader(".content-wrap","in");
+	organizrAPI2('DELETE','api/v2/plugins/invites/' + code).success(function(data) {
+		var response = data.response;
+		$('#inviteItem-'+id).remove();
+		//$.magnificPopup.close();
+		ajaxloader();
+		message('Invite',' Invite Deleted',activeInfo.settings.notifications.position,'#FFF','success','5000');
+	}).fail(function(xhr) {
+		console.error("Organizr Function: API Connection Failed");
+		ajaxloader();
+		message('Invite Error',' An Error Occured',activeInfo.settings.notifications.position,'#FFF','error','5000');
+	});
 
 }
 // EVENTS and LISTENERS
 function buildInvites(array){
-    if(array.length == 0){
+	if(array.length == 0){
 		return '<h2 class="text-center" lang="en">No Invites</h2>';
 	}
-    var invites = '';
+	var invites = '';
 	$.each(array, function(i,v) {
-        v.dateused = (v.dateused) ? v.dateused : '-';
-        v.usedby = (v.usedby) ? v.usedby : '-';
-        v.ip = (v.ip) ? v.ip : '-';
-        invites += `
-        <tr id="inviteItem-`+v.id+`">
-            <td class="text-center">`+v.id+`</td>
-            <td>`+v.username+`</td>
-            <td>`+v.email+`</td>
-            <td>`+v.code+`</td>
-            <td>`+v.date+`</td>
-            <td>`+v.dateused+`</td>
-            <td>`+v.usedby+`</td>
-            <td>`+v.ip+`</td>
-            <td>`+v.valid+`</td>
-            <td><button type="button" class="btn btn-danger btn-outline btn-circle btn-lg m-r-5" onclick="deleteInvite('`+v.code+`','`+v.id+`');"><i class="ti-trash"></i></button></td>
-        </tr>
-        `;
-    });
-    return invites;
+		v.dateused = (v.dateused) ? v.dateused : '-';
+		v.usedby = (v.usedby) ? v.usedby : '-';
+		v.ip = (v.ip) ? v.ip : '-';
+		invites += `
+		<tr id="inviteItem-`+v.id+`">
+			<td class="text-center">`+v.id+`</td>
+			<td>`+v.username+`</td>
+			<td>`+v.email+`</td>
+			<td>`+v.code+`</td>
+			<td>`+v.date+`</td>
+			<td>`+v.dateused+`</td>
+			<td>`+v.usedby+`</td>
+			<td>`+v.ip+`</td>
+			<td>`+v.valid+`</td>
+			<td><button type="button" class="btn btn-danger btn-outline btn-circle btn-lg m-r-5" onclick="deleteInvite('`+v.code+`','`+v.id+`');"><i class="ti-trash"></i></button></td>
+		</tr>
+		`;
+	});
+	return invites;
 }
 $(document).on('click', '.inviteModal', function() {
-    var htmlDOM = '';
-    if (activeInfo.user.loggedin === true && activeInfo.user.groupID <= 1) {
-        ajaxloader(".content-wrap","in");
-        organizrAPI2('GET','api/v2/plugins/invites').success(function(data) {
-            var response = data.response;
-            var htmlDOM = '';
-            htmlDOM = `
-            <div class="col-md-12">
-                <div class="panel bg-org panel-info">
-                    <div class="panel-heading">
-                        <span lang="en">Manage Invites</span>
-                        <button type="button" class="btn btn-info btn-circle pull-right popup-with-form" href="#new-invite-area" data-effect="mfp-3d-unfold"><i class="fa fa-plus"></i> </button>
-                    </div>
-                    <div class="table-responsive">
-                        <table class="table table-hover manage-u-table">
-                            <thead>
-                                <tr>
-                                    <th width="70" class="text-center">#</th>
-                                    <th lang="en">USERNAME</th>
-                                    <th lang="en">EMAIL</th>
-                                    <th lang="en">INVITE CODE</th>
-                                    <th lang="en">DATE SENT</th>
-                                    <th lang="en">DATE USED</th>
-                                    <th lang="en">USED BY</th>
-                                    <th lang="en">IP ADDRESS</th>
-                                    <th lang="en">VALID</th>
-                                    <th lang="en">DELETE</th>
-                                </tr>
-                            </thead>
-                            <tbody id="manageInviteTable">
-                                `+buildInvites(response.data)+`
-                            </tbody>
-                        </table>
-                    </div>
-                </div>
-            </div>
-            <div class="clearfix"></div>
-            `;
-            $('.invite-div').html(htmlDOM);
-        }).fail(function(xhr) {
-            console.error("Organizr Function: API Connection Failed");
-        });
-        ajaxloader();
-    }else if (activeInfo.user.loggedin === false){
-        htmlDOM = `
-        <div class="col-md-12">
-            <div class="panel panel-info m-b-0">
-                <div class="panel-heading" lang="en">Use Invite Code</div>
-                <div class="panel-wrapper collapse in" aria-expanded="true">
-                    <div class="panel-body">
-                        <div class="form-group invite-step-1">
-                            <div class="input-group" style="width: 100%;">
-                                <div class="input-group-addon hidden-xs"><i class="ti-lock"></i></div>
-                                <input type="text" class="form-control text-uppercase" id="inviteCodeInput" placeholder="Code" autocomplete="off" autocorrect="off" autocapitalize="off" maxlength="6" spellcheck="false" autofocus="" required="">
-                            </div>
-                            <br />
-                            <button class="btn btn-block btn-info" onclick="verifyInvite();">Verify</button>
-
-                        </div>
-                        <div class="form-group invite-step-2 hidden">
-
-
-                            <div class="row">
-                                <h2 class="text-center" lang="en">Do you have a `+activeInfo.plugins.includes["INVITES-type-include"].toUpperCase()+` account?</h2>
-                                <div class="col-lg-6">
-                                    <button class="btn btn-block btn-info m-b-10" onclick="inviteHasAccount('`+activeInfo.plugins.includes["INVITES-type-include"]+`',true);" lang="en">Yes</button>
-                                </div>
-                                <div class="col-lg-6">
-                                    <button class="btn btn-block btn-primary m-b-10" onclick="inviteHasAccount('`+activeInfo.plugins.includes["INVITES-type-include"]+`',false);" lang="en">No</button>
-                                </div>
-                            </div>
-
-                        </div>
-                        <div class="form-group invite-step-3-plex-yes hidden">
-                            <div class="input-group" style="width: 100%;">
-                                <div class="input-group-addon hidden-xs"><i class="ti-user"></i></div>
-                                <input type="text" class="form-control" id="inviteUsernameInvite" placeholder="Plex Username or Email" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus="" required="">
-                            </div>
-                            <br />
-                            <button class="btn btn-block btn-info" onclick="hasPlexUsername();">Submit</button>
-                        </div>
-                        <div class="form-group invite-step-3-plex-no hidden">
-                            <div class="input-group" style="width: 100%;">
-                                <div class="input-group-addon hidden-xs"><i class="ti-user"></i></div>
-                                <input type="text" class="form-control" id="invitePlexJoinUsername" lang="en" placeholder="Username" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus="" required="">
-                            </div>
-                            <div class="input-group" style="width: 100%;">
-                                <div class="input-group-addon hidden-xs"><i class="ti-email"></i></div>
-                                <input type="text" class="form-control" id="invitePlexJoinEmail" lang="en" placeholder="E-Mail" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" required="">
-                            </div>
-                            <div class="input-group" style="width: 100%;">
-                                <div class="input-group-addon hidden-xs"><i class="ti-user"></i></div>
-                                <input type="password" class="form-control" id="invitePlexJoinPassword" lang="en" placeholder="Password" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"  required="">
-                            </div>
-                            <br />
-                            <button class="btn btn-block btn-info" onclick="joinPlex();">Submit</button>
-                        </div>
-                        <div class="form-group invite-step-4-plex-accept hidden">
-                            <h4 class="" lang="en">You have been invited.  Please goto <a href="https://plex.tv" target="_blank">PLEX.TV</a> and login to accept the invite.  Once you have done that, you may head back here and login with your credentials.</h4>
-                        </div>
-                        <!-- Begin Emby Invites -->
-                        <div class="form-group invite-step-3-emby-yes hidden">
-                            <div class="input-group" style="width: 100%;">
-                                <div class="input-group-addon hidden-xs"><i class="ti-user"></i></div>
-                                <input type="text" class="form-control" id="inviteUsernameInviteEmby" placeholder="Emby Username" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus="" required="">
-                            </div>
-                            <br />
-                            <button class="btn btn-block btn-info" onclick="hasEmbyUsername();">Submit</button>
-                        </div>
-                        <div class="form-group invite-step-3-emby-no hidden">
-                            <div class="input-group" style="width: 100%;">
-                                <div class="input-group-addon hidden-xs"><i class="ti-user"></i></div>
-                                <input type="text" class="form-control" id="inviteEmbyJoinUsername" lang="en" placeholder="Username" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus="" required="">
-                            </div>
-                            <div class="input-group" style="width: 100%;">
-                                <div class="input-group-addon hidden-xs"><i class="ti-email"></i></div>
-                                <input type="text" class="form-control" id="inviteEmbyJoinEmail" lang="en" placeholder="E-Mail" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" required="">
-                            </div>
-                            <div class="input-group" style="width: 100%;">
-                                <div class="input-group-addon hidden-xs"><i class="ti-user"></i></div>
-                                <input type="password" class="form-control" id="inviteEmbyJoinPassword" lang="en" placeholder="Password" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"  required="">
-                            </div>
-                            <br />
-                            <button class="btn btn-block btn-info" onclick="joinEmby();">Submit</button>
-                        </div>
-                        <div class="form-group invite-step-4-emby-accept hidden">
-                            <h4 class="" lang="en">You Have been added to emby!</h4>
-                        </div>
-                    </div>
-                </div>
-            </div>
-        </div>
-        <div class="clearfix"></div>
-        `;
-        $('.invite-div').html(htmlDOM);
-    }
-});
-
-$(document).on('click', '#INVITES-settings-button', function() {
-    ajaxloader(".content-wrap","in");
-    organizrAPI2('GET','api/v2/plugins/invites/settings').success(function(data) {
-        var response = data.response;
-        $('#INVITES-settings-items').html(buildFormGroup(response.data));
-        $('.selectpicker').selectpicker();
-    }).fail(function(xhr) {
-        console.error("Organizr Function: API Connection Failed");
-    });
-    ajaxloader();
-});
+	var htmlDOM = '';
+	if (activeInfo.user.loggedin === true && activeInfo.user.groupID <= 1) {
+		ajaxloader(".content-wrap","in");
+		organizrAPI2('GET','api/v2/plugins/invites').success(function(data) {
+			var response = data.response;
+			var htmlDOM = '';
+			htmlDOM = `
+			<div class="col-md-12">
+				<div class="panel bg-org panel-info">
+					<div class="panel-heading">
+						<span lang="en">Manage Invites</span>
+						<button type="button" class="btn btn-info btn-circle pull-right popup-with-form" href="#new-invite-area" data-effect="mfp-3d-unfold"><i class="fa fa-plus"></i> </button>
+					</div>
+					<div class="table-responsive">
+						<table class="table table-hover manage-u-table">
+							<thead>
+								<tr>
+									<th width="70" class="text-center">#</th>
+									<th lang="en">USERNAME</th>
+									<th lang="en">EMAIL</th>
+									<th lang="en">INVITE CODE</th>
+									<th lang="en">DATE SENT</th>
+									<th lang="en">DATE USED</th>
+									<th lang="en">USED BY</th>
+									<th lang="en">IP ADDRESS</th>
+									<th lang="en">VALID</th>
+									<th lang="en">DELETE</th>
+								</tr>
+							</thead>
+							<tbody id="manageInviteTable">
+								`+buildInvites(response.data)+`
+							</tbody>
+						</table>
+					</div>
+				</div>
+			</div>
+			<div class="clearfix"></div>
+			`;
+			$('.invite-div').html(htmlDOM);
+		}).fail(function(xhr) {
+			console.error("Organizr Function: API Connection Failed");
+		});
+		ajaxloader();
+	}else if (activeInfo.user.loggedin === false){
+		htmlDOM = `
+		<div class="col-md-12">
+			<div class="panel panel-info m-b-0">
+				<div class="panel-heading" lang="en">Use Invite Code</div>
+				<div class="panel-wrapper collapse in" aria-expanded="true">
+					<div class="panel-body">
+						<div class="form-group invite-step-1">
+							<div class="input-group" style="width: 100%;">
+								<div class="input-group-addon hidden-xs"><i class="ti-lock"></i></div>
+								<input type="text" class="form-control text-uppercase" id="inviteCodeInput" placeholder="Code" autocomplete="off" autocorrect="off" autocapitalize="off" maxlength="6" spellcheck="false" autofocus="" required="">
+							</div>
+							<br />
+							<button class="btn btn-block btn-info" onclick="verifyInvite();">Verify</button>
+						</div>
+						<div class="form-group invite-step-2 hidden">
+							<div class="row">
+								<h2 class="text-center" lang="en">Do you have a `+activeInfo.plugins.includes["INVITES-type-include"].toUpperCase()+` account?</h2>
+								<div class="col-lg-6">
+									<button class="btn btn-block btn-info m-b-10" onclick="inviteHasAccount('`+activeInfo.plugins.includes["INVITES-type-include"]+`',true);" lang="en">Yes</button>
+								</div>
+								<div class="col-lg-6">
+									<button class="btn btn-block btn-primary m-b-10" onclick="inviteHasAccount('`+activeInfo.plugins.includes["INVITES-type-include"]+`',false);" lang="en">No</button>
+								</div>
+							</div>
+						</div>
+						<div class="form-group invite-step-3-plex-yes hidden">
+							<div class="input-group" style="width: 100%;">
+								<div class="input-group-addon hidden-xs"><i class="ti-user"></i></div>
+								<input type="text" class="form-control" id="inviteUsernameInvite" placeholder="Plex Username or Email" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus="" required="">
+							</div>
+							<br />
+							<button class="btn btn-block btn-info" onclick="hasPlexUsername();">Submit</button>
+						</div>
+						<div class="form-group invite-step-3-plex-no hidden">
+							<div class="input-group" style="width: 100%;">
+								<div class="input-group-addon hidden-xs"><i class="ti-user"></i></div>
+								<input type="text" class="form-control" id="invitePlexJoinUsername" lang="en" placeholder="Username" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus="" required="">
+							</div>
+							<div class="input-group" style="width: 100%;">
+								<div class="input-group-addon hidden-xs"><i class="ti-email"></i></div>
+								<input type="text" class="form-control" id="invitePlexJoinEmail" lang="en" placeholder="E-Mail" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" required="">
+							</div>
+							<div class="input-group" style="width: 100%;">
+								<div class="input-group-addon hidden-xs"><i class="ti-user"></i></div>
+								<input type="password" class="form-control" id="invitePlexJoinPassword" lang="en" placeholder="Password" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"  required="">
+							</div>
+							<br />
+							<button class="btn btn-block btn-info" onclick="joinPlex();">Submit</button>
+						</div>
+						<div class="form-group invite-step-4-plex-accept hidden">
+							<h4 class="" lang="en">You have been invited.  Please check your email or goto <a href="https://plex.tv" target="_blank">PLEX.TV</a> and login to accept the invite.  Once you have done that, you may head back here and login with your credentials.</h4>
+						</div>
+						<!-- Begin Emby Invites -->
+						<div class="form-group invite-step-3-emby-yes hidden">
+							<div class="input-group" style="width: 100%;">
+								<div class="input-group-addon hidden-xs"><i class="ti-user"></i></div>
+								<input type="text" class="form-control" id="inviteUsernameInviteEmby" placeholder="Emby Username" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus="" required="">
+							</div>
+							<br />
+							<button class="btn btn-block btn-info" onclick="hasEmbyUsername();">Submit</button>
+						</div>
+						<div class="form-group invite-step-3-emby-no hidden">
+							<div class="input-group" style="width: 100%;">
+								<div class="input-group-addon hidden-xs"><i class="ti-user"></i></div>
+								<input type="text" class="form-control" id="inviteEmbyJoinUsername" lang="en" placeholder="Username" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus="" required="">
+							</div>
+							<div class="input-group" style="width: 100%;">
+								<div class="input-group-addon hidden-xs"><i class="ti-email"></i></div>
+								<input type="text" class="form-control" id="inviteEmbyJoinEmail" lang="en" placeholder="E-Mail" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" required="">
+							</div>
+							<div class="input-group" style="width: 100%;">
+								<div class="input-group-addon hidden-xs"><i class="ti-user"></i></div>
+								<input type="password" class="form-control" id="inviteEmbyJoinPassword" lang="en" placeholder="Password" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"  required="">
+							</div>
+							<br />
+							<button class="btn btn-block btn-info" onclick="joinEmby();">Submit</button>
+						</div>
+						<div class="form-group invite-step-4-emby-accept hidden">
+							<h4 class="" lang="en">You Have been added to emby!</h4>
+						</div>
+					</div>
+				</div>
+			</div>
+		</div>
+		<div class="clearfix"></div>
+		`;
+		$('.invite-div').html(htmlDOM);
+	}
+});

+ 205 - 224
api/plugins/js/php-mailer.js

@@ -1,263 +1,244 @@
 /* PHP MAILER JS FILE */
-
+$('body').arrive('#activeInfo', {onceOnly: true}, function() {
+	phpmLaunch();
+});
 // FUNCTIONS
-phpmLaunch();
+
 function phpmLaunch(){
-    if(typeof activeInfo == 'undefined'){
-        setTimeout(function () {
-            phpmLaunch();
-        }, 1000);
-    }else{
-        if(activeInfo.plugins["PHPMAILER-enabled"] == true){
-            if (activeInfo.user.loggedin === true && activeInfo.user.groupID <= 1) {
-                var menuList = `<li><a class="inline-popups emailModal" href="#email-area" data-effect="mfp-zoom-out"><i class="fa fa-envelope fa-fw"></i> <span lang="en">E-Mail Center</span></a></li>`;
-                var htmlDOM = `
-            	<div id="email-area" class="white-popup mfp-with-anim mfp-hide">
-            		<div class="col-md-10 col-md-offset-1">
-            			<div class="email-div"></div>
-            		</div>
-            	</div>
-            	`;
-                $('.organizr-area').after(htmlDOM);
-                $('.append-menu').after(menuList);
-                pageLoad();
-            }
-        }
-    }
+	if(activeInfo.plugins["PHPMAILER-enabled"] == true){
+		if (activeInfo.user.loggedin === true && activeInfo.user.groupID <= 1) {
+			var menuList = `<li><a class="inline-popups emailModal" href="#email-area" data-effect="mfp-zoom-out"><i class="fa fa-envelope fa-fw"></i> <span lang="en">E-Mail Center</span></a></li>`;
+			var htmlDOM = `
+			<div id="email-area" class="white-popup mfp-with-anim mfp-hide">
+				<div class="col-md-10 col-md-offset-1">
+					<div class="email-div"></div>
+				</div>
+			</div>
+			`;
+			$('.organizr-area').after(htmlDOM);
+			$('.append-menu').after(menuList);
+			pageLoad();
+		}
+	}
+
 }
 function sendMail(){
-    var to = $('#sendEmailToInput').val();
-    var subject = $('#sendEmailSubjectInput').val();
-    var body = tinyMCE.get('sendEmail').getContent();
-    if(to == ''){
-        messageSingle('','Please Enter Email',activeInfo.settings.notifications.position,'#FFF','error','5000');
-    }else if(subject == ''){
-        messageSingle('','Please Enter Subject',activeInfo.settings.notifications.position,'#FFF','error','5000');
-    }else if(body == ''){
-        messageSingle('','Please Enter Body',activeInfo.settings.notifications.position,'#FFF','error','5000');
-    }else{
-	    messageSingle('','Sending Message',activeInfo.settings.notifications.position,'#FFF','success','5000');
-    }
-    if(to !== '' && subject !== '' && body !== ''){
-        var post = {
-            bcc:to,
-            subject:subject,
-            body:body
-        };
-        ajaxloader(".content-wrap","in");
-        organizrAPI2('POST','api/v2/plugins/php-mailer/email/send',post).success(function(data) {
-            var response = data.response;
-            if(response.result == 'success'){
-                $.magnificPopup.close();
-                messageSingle('',window.lang.translate('Email Sent Successful'),activeInfo.settings.notifications.position,'#FFF','success','5000');
-            }else{
-                messageSingle('',response.message,activeInfo.settings.notifications.position,'#FFF','error','5000');
-            }
-        }).fail(function(xhr) {
-            console.error("Organizr Function: API Connection Failed");
-        });
-        ajaxloader();
-    }
+	var to = $('#sendEmailToInput').val();
+	var subject = $('#sendEmailSubjectInput').val();
+	var body = tinyMCE.get('sendEmail').getContent();
+	if(to == ''){
+		messageSingle('','Please Enter Email',activeInfo.settings.notifications.position,'#FFF','error','5000');
+	}else if(subject == ''){
+		messageSingle('','Please Enter Subject',activeInfo.settings.notifications.position,'#FFF','error','5000');
+	}else if(body == ''){
+		messageSingle('','Please Enter Body',activeInfo.settings.notifications.position,'#FFF','error','5000');
+	}else{
+		messageSingle('','Sending Message',activeInfo.settings.notifications.position,'#FFF','success','5000');
+	}
+	if(to !== '' && subject !== '' && body !== ''){
+		var post = {
+			bcc:to,
+			subject:subject,
+			body:body
+		};
+		ajaxloader(".content-wrap","in");
+		organizrAPI2('POST','api/v2/plugins/php-mailer/email/send',post).success(function(data) {
+			var response = data.response;
+			if(response.result == 'success'){
+				$.magnificPopup.close();
+				messageSingle('',window.lang.translate('Email Sent Successful'),activeInfo.settings.notifications.position,'#FFF','success','5000');
+			}else{
+				messageSingle('',response.message,activeInfo.settings.notifications.position,'#FFF','error','5000');
+			}
+		}).fail(function(xhr) {
+			OrganizrApiError(xhr);
+		});
+		ajaxloader();
+	}
 }
 function buildUserList(array){
-    var users = '';
-    var htmlDOM = '';
+	var users = '';
+	var htmlDOM = '';
 	$.each(array, function(i,v) {
-        users += '<option value="'+v+'">'+i+'</option>';
-    });
-    htmlDOM = `
-    <select multiple id="email-user-list" name="email-user-list[]">`+users+`</select>
-    <div class="button-box m-t-20">
-        <a id="select-all-users-list" class="btn btn-danger btn-outline" href="#">select all</a>
-        <a id="deselect-all-users-list" class="btn btn-info btn-outline" href="#">deselect all</a>
-        <a id="minimize-users-list" class="btn btn-primary btn-outline" href="#">minimize</a>
-    </div>`;
-    return htmlDOM;
+		users += '<option value="'+v+'">'+i+'</option>';
+	});
+	htmlDOM = `
+	<select multiple id="email-user-list" name="email-user-list[]">`+users+`</select>
+	<div class="button-box m-t-20">
+		<a id="select-all-users-list" class="btn btn-danger btn-outline" href="#">select all</a>
+		<a id="deselect-all-users-list" class="btn btn-info btn-outline" href="#">deselect all</a>
+		<a id="minimize-users-list" class="btn btn-primary btn-outline" href="#">minimize</a>
+	</div>`;
+	return htmlDOM;
 }
 function buildEmailModal(){
-    var htmlDOM = `
-    <div class="row">
-        <div class="col-md-12">
-            <div class="panel panel-info m-0">
-                <div class="panel-heading">
-                    <span lang="en">Email Users</span>
-                    <div class="btn-group pull-right">
-
+	var htmlDOM = `
+	<div class="row">
+		<div class="col-md-12">
+			<div class="panel panel-info m-0">
+				<div class="panel-heading">
+					<span lang="en">Email Users</span>
+					<div class="btn-group pull-right">
 						<button class="btn btn-info waves-effect waves-light loadUserList" type="button">
 							<i class="fa fa-user"></i>
 						</button>
-                        <button class="btn btn-info waves-effect waves-light" type="button" onclick="$('.mce-i-template').trigger('click');">
+						<button class="btn btn-info waves-effect waves-light" type="button" onclick="$('.mce-i-template').trigger('click');">
 							<i class="fa fa-files-o"></i>
 						</button>
-                        <button class="btn btn-info waves-effect waves-light unhide-user-list hidden" type="button">
+						<button class="btn btn-info waves-effect waves-light unhide-user-list hidden" type="button">
 							<i class="fa fa-eye"></i>
 						</button>
 						<button class="btn btn-info waves-effect waves-light" onclick="sendMail();"><i class="fa fa-paper-plane"></i></button>
-
-	                </div>
-                </div>
-                <div class="panel-wrapper collapse in main-email-panel" aria-expanded="true">
-                    <div class="panel-body">
-                        <div class="form-body">
-                            <div class="row">
-                                <div class="col-md-6">
-                                    <div class="form-group">
-                                        <label class="control-label" lang="en">To:</label>
-                                        <input type="text" id="sendEmailToInput" class="form-control"></div>
-                                </div>
-                                <div class="col-md-6">
-                                    <div class="form-group">
-                                        <label class="control-label" lang="en">Subject</label>
-                                        <input type="text" id="sendEmailSubjectInput" class="form-control"></div>
-                                </div>
-                                <div class="col-md-12" id="user-list-div">
-
-
-                                </div>
-                            </div>
-                            <!--/row-->
-                        </div>
-                    </div>
-                </div>
-            </div>
-        </div>
-    </div>
-    <textarea id="sendEmail" name="area"></textarea>
-    `;
-    $('.email-div').html(htmlDOM);
-    if ($("#sendEmail").length > 0) {
-        var templates = [];
-        if(activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-One"] !== ''){
-            templates.push(
-                {
-                    title: activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-OneName"],
-                    description: activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-OneSubject"],
-                    content: activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-One"],
-                }
-            )
-        }
-        if(activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-Two"] !== ''){
-            templates.push(
-                {
-                    title: activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-TwoName"],
-                    description: activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-TwoSubject"],
-                    content: activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-Two"],
-                }
-            )
-        }
-        if(activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-Three"] !== ''){
-            templates.push(
-                {
-                    title: activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-ThreeName"],
-                    description: activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-ThreeSubject"],
-                    content: activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-Three"],
-                }
-            )
-        }
-        if(activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-Four"] !== ''){
-            templates.push(
-                {
-                    title: activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-FourName"],
-                    description: activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-FourSubject"],
-                    content: activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-Four"],
-                }
-            )
-        }
-        tinymce.init({
-            selector: "textarea#sendEmail",
-            theme: "modern",
-            height: 300,
-            plugins: [
-                "advlist autolink link image lists charmap print preview hr anchor pagebreak spellchecker", "searchreplace wordcount visualblocks visualchars code fullscreen insertdatetime media nonbreaking", "save table contextmenu directionality emoticons template paste textcolor"
-            ],
-            toolbar: "insertfile template undo redo | styleselect | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link image | print preview media fullpage | forecolor backcolor",
-            templates: templates,
-            init_instance_callback: function (editor) {
-                editor.on('BeforeSetContent', function (e) {
-                    //tinyMCE.get('sendEmail').execCommand('selectAll');
-                    //tinyMCE.get('sendEmail').execCommand('delete');
-                    $.each(e.target.settings.templates, function(i,v) {
-                        if($.trim(v.content) == $.trim(e.content)){
-                            $('#sendEmailSubjectInput').val(v.description);
-                        }
-                    });
-                });
-              }
-        });
-    }
+					</div>
+				</div>
+				<div class="panel-wrapper collapse in main-email-panel" aria-expanded="true">
+					<div class="panel-body">
+						<div class="form-body">
+							<div class="row">
+								<div class="col-md-6">
+									<div class="form-group">
+										<label class="control-label" lang="en">To:</label>
+										<input type="text" id="sendEmailToInput" class="form-control"></div>
+								</div>
+								<div class="col-md-6">
+									<div class="form-group">
+										<label class="control-label" lang="en">Subject</label>
+										<input type="text" id="sendEmailSubjectInput" class="form-control"></div>
+								</div>
+								<div class="col-md-12" id="user-list-div"></div>
+							</div>
+							<!--/row-->
+						</div>
+					</div>
+				</div>
+			</div>
+		</div>
+	</div>
+	<textarea id="sendEmail" name="area"></textarea>
+	`;
+	$('.email-div').html(htmlDOM);
+	if ($("#sendEmail").length > 0) {
+		var templates = [];
+		if(activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-One"] !== ''){
+			templates.push(
+				{
+					title: activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-OneName"],
+					description: activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-OneSubject"],
+					content: activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-One"],
+				}
+			)
+		}
+		if(activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-Two"] !== ''){
+			templates.push(
+				{
+					title: activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-TwoName"],
+					description: activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-TwoSubject"],
+					content: activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-Two"],
+				}
+			)
+		}
+		if(activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-Three"] !== ''){
+			templates.push(
+				{
+					title: activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-ThreeName"],
+					description: activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-ThreeSubject"],
+					content: activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-Three"],
+				}
+			)
+		}
+		if(activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-Four"] !== ''){
+			templates.push(
+				{
+					title: activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-FourName"],
+					description: activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-FourSubject"],
+					content: activeInfo.plugins.includes["PHPMAILER-emailTemplateCustom-include-Four"],
+				}
+			)
+		}
+		tinymce.init({
+			selector: "textarea#sendEmail",
+			theme: "modern",
+			height: 300,
+			plugins: [
+				"advlist autolink link image lists charmap print preview hr anchor pagebreak spellchecker", "searchreplace wordcount visualblocks visualchars code fullscreen insertdatetime media nonbreaking", "save table contextmenu directionality emoticons template paste textcolor"
+			],
+			toolbar: "insertfile template undo redo | styleselect | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link image | print preview media fullpage | forecolor backcolor",
+			templates: templates,
+			init_instance_callback: function (editor) {
+				editor.on('BeforeSetContent', function (e) {
+					//tinyMCE.get('sendEmail').execCommand('selectAll');
+					//tinyMCE.get('sendEmail').execCommand('delete');
+					$.each(e.target.settings.templates, function(i,v) {
+						if($.trim(v.content) == $.trim(e.content)){
+							$('#sendEmailSubjectInput').val(v.description);
+						}
+					});
+				});
+			  }
+		});
+	}
 
 }
 // EVENTS and LISTENERS
 $(document).on("change", "#email-user-list", function () {
-    $('#sendEmailToInput').val($('#email-user-list').val());
+	$('#sendEmailToInput').val($('#email-user-list').val());
 });
 $(document).on('click', '.loadUserList', function() {
-    ajaxloader(".content-wrap","in");
-    organizrAPI2('GET','api/v2/plugins/php-mailer/email/list').success(function(data) {
-        var response = data.response;
-        $('#user-list-div').html(buildUserList(response.data));
-        $('#email-user-list').multiSelect();
-    }).fail(function(xhr) {
-        console.error("Organizr Function: API Connection Failed");
-    });
-    ajaxloader();
+	ajaxloader(".content-wrap","in");
+	organizrAPI2('GET','api/v2/plugins/php-mailer/email/list').success(function(data) {
+		var response = data.response;
+		$('#user-list-div').html(buildUserList(response.data));
+		$('#email-user-list').multiSelect();
+	}).fail(function(xhr) {
+		OrganizrApiError(xhr);
+	});
+	ajaxloader();
 });
 $(document).on("click", ".emailModal", function(e) {
-    buildEmailModal();
+	buildEmailModal();
 });
 $(document).on("click", ".show-login", function(e) {
-    setTimeout(addForgotPassword, 1000);
+	setTimeout(addForgotPassword, 1000);
 });
 $(document).on("click", "#select-all-users-list", function(e) {
-    $('#email-user-list').multiSelect('select_all');
-    return false;
+	$('#email-user-list').multiSelect('select_all');
+	return false;
 });
 $(document).on("click", "#deselect-all-users-list", function(e) {
-    $('#email-user-list').multiSelect('deselect_all');
-    return false;
+	$('#email-user-list').multiSelect('deselect_all');
+	return false;
 });
 $(document).on("click", "#minimize-users-list, .unhide-user-list", function(e) {
-    $('.main-email-panel').toggleClass('hidden');
-    $('.loadUserList').toggleClass('hidden');
-    $('.unhide-user-list').toggleClass('hidden');
-    return false;
+	$('.main-email-panel').toggleClass('hidden');
+	$('.loadUserList').toggleClass('hidden');
+	$('.unhide-user-list').toggleClass('hidden');
+	return false;
 });
 function addForgotPassword(){
-    var item = '';
-    if(activeInfo.plugins["PHPMAILER-enabled"] == true){
-        if (activeInfo.user.loggedin === false) {
-            item = `<a href="javascript:void(0)" id="to-recover" class="text-dark pull-right"><i class="fa fa-lock m-r-5"></i> <span lang="en">Forgot pwd?</span></a>`;
-            $('.remember-me').after(item);
-        }
-    }
+	var item = '';
+	if(activeInfo.plugins["PHPMAILER-enabled"] == true){
+		if (activeInfo.user.loggedin === false) {
+			item = `<a href="javascript:void(0)" id="to-recover" class="text-dark pull-right"><i class="fa fa-lock m-r-5"></i> <span lang="en">Forgot pwd?</span></a>`;
+			$('.remember-me').after(item);
+		}
+	}
 }
-$(document).on('click', '#PHPMAILER-settings-button', function() {
-    ajaxloader(".content-wrap","in");
-    organizrAPI2('GET','api/v2/plugins/php-mailer/settings').success(function(data) {
-        var response = data.response;
-        $('#PHPMAILER-settings-items').html(buildFormGroup(response.data));
-    }).fail(function(xhr) {
-        console.error("Organizr Function: API Connection Failed");
-    });
-    ajaxloader();
-});
 // SEND TEST EMAIL
 $(document).on('click', '.phpmSendTestEmail', function() {
-    messageSingle('',window.lang.translate('Sending Test E-Mail'),activeInfo.settings.notifications.position,'#FFF','info','5000');
-    ajaxloader(".content-wrap","in");
-    organizrAPI2('GET','api/v2/plugins/php-mailer/email/test').success(function(data) {
-        var response = data.response;
-        if(response.message !== null && response.message.indexOf('|||DEBUG|||') == 0){
-            messageSingle('',window.lang.translate('Press F11 to check Console for output'),activeInfo.settings.notifications.position,'#FFF','warning','5000');
-	        console.warn(response.message);
-        }else if(response.result == 'success') {
-            messageSingle('',window.lang.translate('Email Test Successful'),activeInfo.settings.notifications.position,'#FFF','success','20000');
-        }else{
-            messageSingle('',response.message,activeInfo.settings.notifications.position,'#FFF','error','5000');
-        }
-    }).fail(function(xhr, data) {
-    	console.log(data)
-        console.error("Organizr Function: API Connection Failed");
-    });
-    ajaxloader();
-});
+	messageSingle('',window.lang.translate('Sending Test E-Mail'),activeInfo.settings.notifications.position,'#FFF','info','5000');
+	ajaxloader(".content-wrap","in");
+	organizrAPI2('GET','api/v2/plugins/php-mailer/email/test').success(function(data) {
+		var response = data.response;
+		if(response.message !== null && response.message.indexOf('|||DEBUG|||') == 0){
+			messageSingle('',window.lang.translate('Press F11 to check Console for output'),activeInfo.settings.notifications.position,'#FFF','warning','5000');
+			console.warn(response.message);
+		}else if(response.result == 'success') {
+			messageSingle('',window.lang.translate('Email Test Successful'),activeInfo.settings.notifications.position,'#FFF','success','20000');
+		}else{
+			messageSingle('',response.message,activeInfo.settings.notifications.position,'#FFF','error','5000');
+		}
+	}).fail(function(xhr, data) {
+		OrganizrApiError(xhr, 'Mailer Error');
+	});
+	ajaxloader();
+});

+ 57 - 72
api/plugins/js/speedTest.js

@@ -1,4 +1,7 @@
 /* SPEEDTEST JS FILE */
+$('body').arrive('#activeInfo', {onceOnly: true}, function() {
+	speedTestLaunch();
+});
 function clamp(num, min, max) {
   return num <= min ? min : num >= max ? max : num;
 }
@@ -56,82 +59,64 @@ function initUI(){
 	$('#uploadPercent').attr('class', 'css-bar css-bar-0 css-bar-lg css-bar-warning pull-right').attr('data-label', '0Mbps');
 }
 // FUNCTIONS
-speedTestLaunch()
 function speedTestLaunch(){
-    if(typeof activeInfo == 'undefined'){
-        setTimeout(function () {
-            speedTestLaunch();
-        }, 1000);
-    }else{
-        if(activeInfo.plugins["SPEEDTEST-enabled"] == true){
-            if (activeInfo.user.groupID <= activeInfo.plugins.includes["SPEEDTEST-Auth-include"]) {
-                var menuList = `<li><a class="inline-popups speedTestModal" href="#speedtest-area" data-effect="mfp-zoom-out"><i class="fa fa-rocket fa-fw"></i> <span lang="en">Test Server Speed</span></a></li>`;
-				var htmlDOM = `
-		    	<div id="speedtest-area" class="white-popup mfp-with-anim mfp-hide">
-		    		<div class="col-md-4 col-md-offset-4">
-						<div class="panel bg-org panel-info">
-							<div class="panel-heading">
-								<span lang="en">Test Speed to Server</span>
-								<button id="startStopBtn" onclick="startStop()" class="btn btn-info waves-effect waves-light pull-right"><span lang="en" id="speedTestButtonText">Start</span> <i class="fa fa-rocket m-l-5"></i></button>
-							</div>
-							<div class="panel-body">
-								<div id="test">
-									<div class="row hidden-xs">
-										<div class="col-md-6 col-xs-6"><div id="downloadPercent" data-label="0Mbps" style="font-size: 15px;"></div></div>
-										<div class="col-md-6 col-xs-6"><div id="uploadPercent" data-label="0Mbps" style="font-size: 15px;"></div></div>
+	if(activeInfo.plugins["SPEEDTEST-enabled"] == true){
+		if (activeInfo.user.groupID <= activeInfo.plugins.includes["SPEEDTEST-Auth-include"]) {
+			var menuList = `<li><a class="inline-popups speedTestModal" href="#speedtest-area" data-effect="mfp-zoom-out"><i class="fa fa-rocket fa-fw"></i> <span lang="en">Test Server Speed</span></a></li>`;
+			var htmlDOM = `
+			<div id="speedtest-area" class="white-popup mfp-with-anim mfp-hide">
+				<div class="col-md-4 col-md-offset-4">
+					<div class="panel bg-org panel-info">
+						<div class="panel-heading">
+							<span lang="en">Test Speed to Server</span>
+							<button id="startStopBtn" onclick="startStop()" class="btn btn-info waves-effect waves-light pull-right"><span lang="en" id="speedTestButtonText">Start</span> <i class="fa fa-rocket m-l-5"></i></button>
+						</div>
+						<div class="panel-body">
+							<div id="test">
+								<div class="row hidden-xs">
+									<div class="col-md-6 col-xs-6"><div id="downloadPercent" data-label="0Mbps" style="font-size: 15px;"></div></div>
+									<div class="col-md-6 col-xs-6"><div id="uploadPercent" data-label="0Mbps" style="font-size: 15px;"></div></div>
+								</div>
+								<div class="progress progress-sm">
+									<div id="progress" class="progress-bar progress-bar-info active progress-bar-striped" role="progressbar" aria-valuenow="40" aria-valuemin="0" aria-valuemax="100" style="width: 0%">
+										<span class="sr-only">0% Complete (success)</span>
 									</div>
-									<div class="progress progress-sm">
-										<div id="progress" class="progress-bar progress-bar-info active progress-bar-striped" role="progressbar" aria-valuenow="40" aria-valuemin="0" aria-valuemax="100" style="width: 0%">
-											<span class="sr-only">0% Complete (success)</span>
+								</div>
+								<div class="white-box m-b-0">
+									<div class="user-btm-box">
+										<div class="col-md-3 col-xs-6 p-l-0 p-r-0 text-center">
+											<p class="text-success"><i class="ti-download fa-2x"></i></p>
+											<h1 id="dlText"></h1>
+											<h4 class="">Mbps</h4>
+										</div>
+										<div class="col-md-3 col-xs-6 p-l-0 p-r-0 text-center">
+											<p class="text-warning"><i class="ti-upload fa-2x"></i></p>
+											<h1 id="ulText"></h1>
+											<h4 class="">Mbps</h4>
+										</div>
+										<div class="col-md-3 col-xs-6 p-l-0 p-r-0 text-center">
+											<p class="text-purple"><i class="ti-direction-alt fa-2x"></i></p>
+											<h1 id="pingText"></h1>
+											<h4 class="">ms</h4>
+										</div>
+										<div class="col-md-3 col-xs-6 p-l-0 p-r-0 text-center">
+											<p class="text-info"><i class="ti-pulse fa-2x"></i></p>
+											<h1 id="jitText"></h1>
+											<h4 class="">ms</h4>
 										</div>
 									</div>
-				                    <div class="white-box m-b-0">
-				                        <div class="user-btm-box">
-				                            <div class="col-md-3 col-xs-6 p-l-0 p-r-0 text-center">
-				                                <p class="text-success"><i class="ti-download fa-2x"></i></p>
-				                                <h1 id="dlText"></h1>
-												<h4 class="">Mbps</h4>
-											</div>
-				                            <div class="col-md-3 col-xs-6 p-l-0 p-r-0 text-center">
-				                                <p class="text-warning"><i class="ti-upload fa-2x"></i></p>
-				                                <h1 id="ulText"></h1>
-												<h4 class="">Mbps</h4>
-											</div>
-				                            <div class="col-md-3 col-xs-6 p-l-0 p-r-0 text-center">
-				                                <p class="text-purple"><i class="ti-direction-alt fa-2x"></i></p>
-				                                <h1 id="pingText"></h1>
-												<h4 class="">ms</h4>
-											</div>
-				                            <div class="col-md-3 col-xs-6 p-l-0 p-r-0 text-center">
-				                                <p class="text-info"><i class="ti-pulse fa-2x"></i></p>
-				                                <h1 id="jitText"></h1>
-												<h4 class="">ms</h4>
-											</div>
-				                        </div>
-				                    </div>
 								</div>
-								<script type="text/javascript">initUI();</script>
 							</div>
-							<div class="panel-footer"> IP Address: <span id="ip"></span> </div>
+							<script type="text/javascript">initUI();</script>
 						</div>
-		    		</div>
-		    	</div>
-		    	`;
-				$('.append-menu').after(menuList);
-	            $('.organizr-area').after(htmlDOM);
-	            pageLoad();
-			}
-        }
-    }
-}
-
-$(document).on('click', '#SPEEDTEST-settings-button', function() {
-    ajaxloader(".content-wrap","in");
-    organizrAPI2('GET','api/v2/plugins/speedtest/settings').success(function(data) {
-        var response = data.response;
-        $('#SPEEDTEST-settings-items').html(buildFormGroup(response.data));
-    }).fail(function(xhr) {
-        console.error("Organizr Function: API Connection Failed");
-    });
-    ajaxloader();
-});
+						<div class="panel-footer"> IP Address: <span id="ip"></span> </div>
+					</div>
+				</div>
+			</div>
+			`;
+			$('.append-menu').after(menuList);
+			$('.organizr-area').after(htmlDOM);
+			pageLoad();
+		}
+	}
+}

+ 4 - 2
api/plugins/php-mailer.php

@@ -10,7 +10,9 @@ $GLOBALS['plugins'][]['PHP Mailer'] = array( // Plugin Name
 	'configPrefix' => 'PHPMAILER', // config file prefix for array items without the hyphen
 	'version' => '1.0.0', // SemVer of plugin
 	'image' => 'plugins/images/php-mailer.png', // 1:1 non transparent image for plugin
-	'settings' => true, // does plugin need a settings page? true or false
+	'settings' => true, // does plugin need a settings modal?
+	'bind' => true, // use default bind to make settings page - true or false
+	'api' => 'api/v2/plugins/php-mailer/settings', // api route for settings page
 	'homepage' => false // Is plugin for use on homepage? true or false
 );
 
@@ -535,4 +537,4 @@ class PhpMailer extends Organizr
 			)
 		);
 	}
-}
+}

+ 5 - 3
api/plugins/speedTest.php

@@ -4,13 +4,15 @@ $GLOBALS['plugins'][]['SpeedTest'] = array( // Plugin Name
 	'name' => 'SpeedTest', // Plugin Name
 	'author' => 'CauseFX', // Who wrote the plugin
 	'category' => 'Utilities', // One to Two Word Description
-	'link' => 'https://github.com/PHPMailer/PHPMailer', // Link to plugin info
+	'link' => '', // Link to plugin info
 	'license' => 'personal,business', // License Type use , for multiple
 	'idPrefix' => 'SPEEDTEST', // html element id prefix
 	'configPrefix' => 'SPEEDTEST', // config file prefix for array items without the hypen
 	'version' => '1.0.0', // SemVer of plugin
 	'image' => 'plugins/images/speedtest.png', // 1:1 non transparent image for plugin
-	'settings' => true, // does plugin need a settings page? true or false
+	'settings' => true, // does plugin need a settings modal?
+	'bind' => true, // use default bind to make settings page - true or false
+	'api' => 'api/v2/plugins/speedtest/settings', // api route for settings page
 	'homepage' => false // Is plugin for use on homepage? true or false
 );
 
@@ -30,4 +32,4 @@ class SpeedTest extends Organizr
 			)
 		);
 	}
-}
+}

+ 6 - 0
api/v2/index.php

@@ -96,6 +96,12 @@ $app->add(function ($request, $handler) {
 foreach (glob(__DIR__ . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . '*.php') as $filename) {
 	require_once $filename;
 }
+/*
+ * Include all custom routes
+ */
+foreach (glob(__DIR__ . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . 'custom' . DIRECTORY_SEPARATOR . '*.php') as $filename) {
+	require_once $filename;
+}
 /*
  * Include all Plugin routes
  */

+ 41 - 0
api/v2/routes/backup.php

@@ -0,0 +1,41 @@
+<?php
+$app->get('/backup', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	if ($Organizr->qualifyRequest(1, true)) {
+		$Organizr->getBackups();
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->post('/backup', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	if ($Organizr->qualifyRequest(1, true)) {
+		$Organizr->backupOrganizr();
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->get('/backup/{filename}', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	if ($Organizr->qualifyRequest(1, true)) {
+		$Organizr->downloadBackup($args['filename']);
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->delete('/backup/{filename}', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	if ($Organizr->qualifyRequest(1, true)) {
+		$Organizr->deleteBackup($args['filename']);
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});

+ 46 - 0
api/v2/routes/connectionTester.php

@@ -5,6 +5,52 @@
  *     description="Test Connections"
  * )
  */
+$app->post('/test/ldap', function ($request, $response, $args) {
+	/**
+	 * @OA\Post(
+	 *     security={{ "api_key":{} }},
+	 *     tags={"test connection"},
+	 *     path="/api/v2/test/ldap",
+	 *     summary="Test LDAP connection",
+	 *     @OA\Response(response="200",description="Success",@OA\JsonContent(ref="#/components/schemas/success-message")),
+	 *     @OA\Response(response="401",description="Unauthorized",@OA\JsonContent(ref="#/components/schemas/unauthorized-message")),
+	 *     @OA\Response(response="404",description="Error",@OA\JsonContent(ref="#/components/schemas/error-message")),
+	 *     @OA\Response(response="409",description="Error",@OA\JsonContent(ref="#/components/schemas/error-message")),
+	 * )
+	 */
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	if ($Organizr->qualifyRequest(1, true)) {
+		$Organizr->testConnectionLdap();
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+	
+});
+$app->post('/test/ldap/login', function ($request, $response, $args) {
+	/**
+	 * @OA\Post(
+	 *     security={{ "api_key":{} }},
+	 *     tags={"test connection"},
+	 *     path="/api/v2/test/ldap/login",
+	 *     summary="Test LDAP connection using account login",
+	 *     @OA\Response(response="200",description="Success",@OA\JsonContent(ref="#/components/schemas/success-message")),
+	 *     @OA\Response(response="401",description="Unauthorized",@OA\JsonContent(ref="#/components/schemas/unauthorized-message")),
+	 *     @OA\Response(response="404",description="Error",@OA\JsonContent(ref="#/components/schemas/error-message")),
+	 *     @OA\Response(response="409",description="Error",@OA\JsonContent(ref="#/components/schemas/error-message")),
+	 * )
+	 */
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	if ($Organizr->qualifyRequest(1, true)) {
+		$Organizr->testConnectionLdapLogin($Organizr->apiData($request));
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+	
+});
 $app->post('/test/iframe', function ($request, $response, $args) {
 	/**
 	 * @OA\Post(

+ 27 - 0
api/v2/routes/custom/index.html

@@ -0,0 +1,27 @@
+Place all custom route files here in this directory....
+
+Name file something anything you like...
+<pre>
+&lt;?php
+
+    /*
+     * The first thing you need to edit it the <code>get</code> part - options are get/post/delete/put/options
+     * The second thing you need to edit is the </code>/something</code>
+     * This will be the endpoints name and will be accessible from: http://organizr/api/v2/custom/something
+     */
+
+    $app->get('/custom/something', function ($request, $response, $args) {
+        // Let's define the Organizr Class to the $Organizr variable
+        $Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+        // Now let's set auth on our function, 1 is for co-admin and upto 999 is for Guest
+        if ($Organizr->qualifyRequest(1, true)) {
+            // Let's assign the api response with our function that holds our data...
+            $GLOBALS['api']['response']['data'] = $Organizr->getAllUsers();
+        }
+        // You do not need to change anything else below this line
+        $response->getBody()->write(jsonE($GLOBALS['api']));
+        return $response
+            ->withHeader('Content-Type', 'application/json;charset=UTF-8')
+            ->withStatus($GLOBALS['responseCode']);
+    });
+</pre>

+ 1 - 1
api/v2/routes/emby.php

@@ -20,7 +20,7 @@ $app->post('/emby/register', function ($request, $response, $args) {
 	 */
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
 	if ($Organizr->checkRoute($request)) {
-		embyJoinAPI($Organizr->apiData($request));
+		$Organizr->embyJoinAPI($Organizr->apiData($request));
 	}
 	$response->getBody()->write(jsonE($GLOBALS['api']));
 	return $response

+ 13 - 0
api/v2/routes/help.php

@@ -0,0 +1,13 @@
+<?php
+$app->get('/help/smtp', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	if ($Organizr->checkRoute($request)) {
+		if ($Organizr->qualifyRequest(1, true)) {
+			$GLOBALS['api']['response']['data'] = $Organizr->saveOrganizrSmtpFromAPI();
+		}
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});

+ 24 - 50
api/v2/routes/homepage.php

@@ -7,7 +7,6 @@ $app->get('/homepage/image', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/calendar', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -16,7 +15,6 @@ $app->get('/homepage/calendar', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/plex/streams', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -25,7 +23,6 @@ $app->get('/homepage/plex/streams', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/emby/streams', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -34,7 +31,6 @@ $app->get('/homepage/emby/streams', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/jellyfin/streams', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -43,7 +39,6 @@ $app->get('/homepage/jellyfin/streams', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/plex/recent', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -52,7 +47,6 @@ $app->get('/homepage/plex/recent', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/emby/recent', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -61,7 +55,6 @@ $app->get('/homepage/emby/recent', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/jellyfin/recent', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -70,7 +63,6 @@ $app->get('/homepage/jellyfin/recent', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/plex/playlists', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -79,7 +71,6 @@ $app->get('/homepage/plex/playlists', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/homepage/plex/metadata', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -88,7 +79,6 @@ $app->post('/homepage/plex/metadata', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/homepage/emby/metadata', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -97,7 +87,6 @@ $app->post('/homepage/emby/metadata', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/homepage/jellyfin/metadata', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -106,7 +95,6 @@ $app->post('/homepage/jellyfin/metadata', function ($request, $response, $args)
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/plex/search/{query}', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -115,7 +103,6 @@ $app->get('/homepage/plex/search/{query}', function ($request, $response, $args)
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/pihole/stats', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -124,7 +111,6 @@ $app->get('/homepage/pihole/stats', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/rtorrent/queue', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -133,7 +119,6 @@ $app->get('/homepage/rtorrent/queue', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/sonarr/queue', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -142,7 +127,6 @@ $app->get('/homepage/sonarr/queue', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/sonarr/calendar', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -151,7 +135,6 @@ $app->get('/homepage/sonarr/calendar', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/radarr/queue', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -160,7 +143,6 @@ $app->get('/homepage/radarr/queue', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/radarr/calendar', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -169,7 +151,6 @@ $app->get('/homepage/radarr/calendar', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/lidarr/calendar', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -178,7 +159,6 @@ $app->get('/homepage/lidarr/calendar', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/couchpotato/calendar', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -187,7 +167,6 @@ $app->get('/homepage/couchpotato/calendar', function ($request, $response, $args
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/sickrage/calendar', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -196,7 +175,6 @@ $app->get('/homepage/sickrage/calendar', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/ical/calendar', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -205,7 +183,6 @@ $app->get('/homepage/ical/calendar', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/deluge/queue', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -214,7 +191,6 @@ $app->get('/homepage/deluge/queue', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/transmission/queue', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -223,7 +199,6 @@ $app->get('/homepage/transmission/queue', function ($request, $response, $args)
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/qbittorrent/queue', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -232,7 +207,6 @@ $app->get('/homepage/qbittorrent/queue', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/jdownloader/queue', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -241,7 +215,6 @@ $app->get('/homepage/jdownloader/queue', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/nzbget/queue', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -250,7 +223,6 @@ $app->get('/homepage/nzbget/queue', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/sabnzbd/queue', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -259,7 +231,6 @@ $app->get('/homepage/sabnzbd/queue', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/homepage/sabnzbd/queue/resume', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -270,7 +241,6 @@ $app->post('/homepage/sabnzbd/queue/resume', function ($request, $response, $arg
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/homepage/sabnzbd/queue/pause', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -281,7 +251,6 @@ $app->post('/homepage/sabnzbd/queue/pause', function ($request, $response, $args
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/unifi/data', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -290,7 +259,6 @@ $app->get('/homepage/unifi/data', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/tautulli/data', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -299,7 +267,14 @@ $app->get('/homepage/tautulli/data', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
+});
+$app->get('/homepage/tautulli/names', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	$Organizr->getTautulliFriendlyNames();
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
 });
 $app->get('/homepage/netdata/data', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -308,7 +283,6 @@ $app->get('/homepage/netdata/data', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/monitorr/data', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -317,7 +291,6 @@ $app->get('/homepage/monitorr/data', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/speedtest/data', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -326,7 +299,6 @@ $app->get('/homepage/speedtest/data', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/octoprint/data', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -335,7 +307,6 @@ $app->get('/homepage/octoprint/data', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/weather/data', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -344,7 +315,6 @@ $app->get('/homepage/weather/data', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/homepage/weather/coordinates', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -353,7 +323,6 @@ $app->post('/homepage/weather/coordinates', function ($request, $response, $args
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/healthchecks', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -362,7 +331,6 @@ $app->get('/homepage/healthchecks', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/healthchecks/{tags}', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -371,7 +339,6 @@ $app->get('/homepage/healthchecks/{tags}', function ($request, $response, $args)
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/ombi/requests[/{type}[/{limit}[/{offset}]]]', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -383,7 +350,6 @@ $app->get('/homepage/ombi/requests[/{type}[/{limit}[/{offset}]]]', function ($re
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/homepage/ombi/requests/{type}/{id}', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -392,7 +358,6 @@ $app->post('/homepage/ombi/requests/{type}/{id}', function ($request, $response,
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/homepage/ombi/requests/{type}/{id}/available', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -401,7 +366,6 @@ $app->post('/homepage/ombi/requests/{type}/{id}/available', function ($request,
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/homepage/ombi/requests/{type}/{id}/unavailable', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -410,7 +374,6 @@ $app->post('/homepage/ombi/requests/{type}/{id}/unavailable', function ($request
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/homepage/ombi/requests/{type}/{id}/approve', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -419,7 +382,6 @@ $app->post('/homepage/ombi/requests/{type}/{id}/approve', function ($request, $r
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->put('/homepage/ombi/requests/{type}/{id}/deny', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -428,7 +390,6 @@ $app->put('/homepage/ombi/requests/{type}/{id}/deny', function ($request, $respo
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->delete('/homepage/ombi/requests/{type}/{id}', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -437,7 +398,6 @@ $app->delete('/homepage/ombi/requests/{type}/{id}', function ($request, $respons
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->get('/homepage/youtube/{query}', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -446,7 +406,6 @@ $app->get('/homepage/youtube/{query}', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/homepage/scrape', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
@@ -455,5 +414,20 @@ $app->post('/homepage/scrape', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
+});
+$app->get('/homepage/jackett/{query}', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	$Organizr->searchJackettIndexers($args['query']);
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->get('/homepage/trakt/calendar', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	$Organizr->getTraktCalendar();
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
 });

+ 12 - 0
api/v2/routes/icon.php

@@ -0,0 +1,12 @@
+<?php
+$app->get('/icon', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	if ($Organizr->qualifyRequest(1, true)) {
+		$GLOBALS['api']['response']['data'] = $Organizr->getIcons();
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+	
+});

+ 11 - 0
api/v2/routes/image.php

@@ -9,6 +9,17 @@ $app->get('/image', function ($request, $response, $args) {
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
 	
+});
+$app->get('/image/select', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	if ($Organizr->qualifyRequest(1, true)) {
+		$GLOBALS['api']['response']['data'] = $Organizr->getImagesSelect();
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+	
 });
 $app->delete('/image/{image}', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();

+ 25 - 0
api/v2/routes/news.php

@@ -0,0 +1,25 @@
+<?php
+$app->get('/news', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	if ($Organizr->checkRoute($request)) {
+		if ($Organizr->qualifyRequest(1, true)) {
+			$Organizr->getNewsIds();
+		}
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->post('/news/{id}', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	if ($Organizr->checkRoute($request)) {
+		if ($Organizr->qualifyRequest(1, true)) {
+			$Organizr->ignoreNewsId($args['id']);
+		}
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json')
+		->withStatus($GLOBALS['responseCode']);
+});

+ 13 - 0
api/v2/routes/oauth.php

@@ -0,0 +1,13 @@
+<?php
+$app->get('/oauth/trakt', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	if ($Organizr->checkRoute($request)) {
+		if ($Organizr->qualifyRequest(1, true)) {
+			$Organizr->traktOAuth();
+		}
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});

+ 9 - 0
api/v2/routes/opencollective.php

@@ -0,0 +1,9 @@
+<?php
+$app->get('/opencollective', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	$Organizr->getOpenCollectiveBackers();
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});

+ 3 - 2
api/v2/routes/root.php

@@ -116,11 +116,12 @@ $app->get('/launch', function ($request, $response, $args) {
 	$GLOBALS['api']['response']['data']['status'] = $Organizr->status();
 	$GLOBALS['api']['response']['data']['sso'] = array(
 		'myPlexAccessToken' => isset($_COOKIE['mpt']) ? $_COOKIE['mpt'] : false,
-		'id_token' => isset($_COOKIE['Auth']) ? $_COOKIE['Auth'] : false
+		'id_token' => isset($_COOKIE['Auth']) ? $_COOKIE['Auth'] : false,
+		'jellyfin_credentials' => isset($_COOKIE['jellyfin_credentials']) ? $_COOKIE['jellyfin_credentials'] : false
 	);
 	$response->getBody()->write(jsonE($GLOBALS['api']));
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
 	
-});
+});

+ 10 - 0
api/v2/routes/settings.php

@@ -39,3 +39,13 @@ $app->get('/settings/homepage', function ($request, $response, $args) {
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
 });
+$app->get('/settings/homepage/{item}', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	if ($Organizr->qualifyRequest(1, true)) {
+		$GLOBALS['api']['response']['data'] = $Organizr->getSettingsHomepageItem($args['item']);
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});

+ 11 - 0
api/v2/routes/tabs.php

@@ -9,6 +9,17 @@ $app->get('/tabs', function ($request, $response, $args) {
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
 	
+});
+$app->get('/tabs/{id}', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	if ($Organizr->qualifyRequest(1, true)) {
+		$GLOBALS['api']['response']['data'] = $Organizr->getTabByIdCheckUser($args['id']);
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+	
 });
 $app->post('/tabs', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();

+ 0 - 1
api/vendor/adldap2/adldap2/.travis.yml

@@ -1,7 +1,6 @@
 language: php
 
 php:
-  - 7.0
   - 7.1
   - 7.2
   - 7.3

+ 3 - 3
api/vendor/adldap2/adldap2/composer.json

@@ -31,11 +31,11 @@
         "ext-json": "*",
         "psr/log": "~1.0",
         "psr/simple-cache": "~1.0",
-        "tightenco/collect": "~5.0|~6.0|~7.0",
-        "illuminate/contracts": "~5.0|~6.0|~7.0"
+        "tightenco/collect": "~5.0|~6.0|~7.0|~8.0",
+        "illuminate/contracts": "~5.0|~6.0|~7.0|~8.0"
     },
     "require-dev": {
-        "phpunit/phpunit": "~6.0",
+        "phpunit/phpunit": "~6.0|~7.0|~8.0",
         "mockery/mockery": "~1.0"
     },
     "suggest": {

+ 6 - 0
api/vendor/adldap2/adldap2/readme.md

@@ -12,6 +12,12 @@
     </strong> </br> Adldap2 will continue to be supported with bug fixes, but will not receive new features.
 </p>
 
+<p align="center">
+ <strong>
+ <a href="https://stevebauman.ca/posts/why-ldap-record/">Read Why</a>
+ </strong>
+</p>
+
 <hr/>
 
 <h1 align="center">Adldap2</h1>

+ 110 - 0
api/vendor/adldap2/adldap2/src/Schemas/Directory389.php

@@ -0,0 +1,110 @@
+<?php
+
+namespace Adldap\Schemas;
+
+class Directory389 extends Schema
+{
+    /**
+     * {@inheritdoc}
+     */
+    public function accountName()
+    {
+        return 'uid';
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function distinguishedName()
+    {
+        return 'dn';
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function distinguishedNameSubKey()
+    {
+        //
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function filterEnabled()
+    {
+        return sprintf('(!(%s=*))', $this->lockoutTime());
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function filterDisabled()
+    {
+        return sprintf('(%s=*)', $this->lockoutTime());
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function lockoutTime()
+    {
+        return 'pwdAccountLockedTime';
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function objectCategory()
+    {
+        return 'objectclass';
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function objectClassGroup()
+    {
+        return 'groupofnames';
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function objectClassOu()
+    {
+        return 'organizationalUnit';
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function objectClassPerson()
+    {
+        return 'inetorgperson';
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function objectClassUser()
+    {
+        return 'inetorgperson';
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function objectGuid()
+    {
+        return 'nsuniqueid';
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function objectGuidRequiresConversion()
+    {
+        return false;
+    }
+}

+ 110 - 0
api/vendor/adldap2/adldap2/src/Schemas/EDirectory.php

@@ -0,0 +1,110 @@
+<?php
+
+namespace Adldap\Schemas;
+
+class EDirectory extends Schema
+{
+    /**
+     * {@inheritdoc}
+     */
+    public function accountName()
+    {
+        return 'uid';
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function distinguishedName()
+    {
+        return 'dn';
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function distinguishedNameSubKey()
+    {
+        //
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function filterEnabled()
+    {
+        return sprintf('(!(%s=*))', $this->lockoutTime());
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function filterDisabled()
+    {
+        return sprintf('(%s=*)', $this->lockoutTime());
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function lockoutTime()
+    {
+        return 'pwdAccountLockedTime';
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function objectCategory()
+    {
+        return 'objectclass';
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function objectClassGroup()
+    {
+        return 'groupofnames';
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function objectClassOu()
+    {
+        return 'organizationalUnit';
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function objectClassPerson()
+    {
+        return 'inetorgperson';
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function objectClassUser()
+    {
+        return 'inetorgperson';
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function objectGuid()
+    {
+        return 'guid';
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function objectGuidRequiresConversion()
+    {
+        return false;
+    }
+}

+ 1 - 1
api/vendor/adldap2/adldap2/src/Utilities.php

@@ -213,7 +213,7 @@ class Utilities
      */
     public static function isValidGuid($guid)
     {
-        return (bool) preg_match('/^([0-9a-fA-F]){8}(-([0-9a-fA-F]){4}){3}-([0-9a-fA-F]){12}$/', $guid);
+        return (bool) preg_match('/^([0-9a-fA-F]){8}(-([0-9a-fA-F]){4}){3}-([0-9a-fA-F]){12}$|^([0-9a-fA-F]{8}-){3}[0-9a-fA-F]{8}$/', $guid);
     }
 
     /**

+ 3 - 0
api/vendor/bogstag/oauth2-trakt/.gitignore

@@ -0,0 +1,3 @@
+/build
+/vendor
+composer.lock

+ 35 - 0
api/vendor/bogstag/oauth2-trakt/.scrutinizer.yml

@@ -0,0 +1,35 @@
+filter:
+    excluded_paths: [tests/*]
+checks:
+    php:
+        code_rating: true
+        remove_extra_empty_lines: true
+        remove_php_closing_tag: true
+        remove_trailing_whitespace: true
+        fix_use_statements:
+            remove_unused: true
+            preserve_multiple: false
+            preserve_blanklines: true
+            order_alphabetically: true
+        fix_php_opening_tag: true
+        fix_linefeed: true
+        fix_line_ending: true
+        fix_identation_4spaces: true
+        fix_doc_comments: true
+tools:
+    external_code_coverage:
+        timeout: 600
+        runs: 3
+    php_analyzer: true
+    php_code_coverage: false
+    php_code_sniffer:
+        config:
+            standard: PSR2
+        filter:
+            paths: ['src']
+    php_loc:
+        enabled: true
+        excluded_dirs: [vendor, tests]
+    php_cpd:
+        enabled: true
+        excluded_dirs: [vendor, tests]

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません