diff --git a/PRIVACY b/PRIVACY
new file mode 100644
index 0000000..f0027ae
--- /dev/null
+++ b/PRIVACY
@@ -0,0 +1,8 @@
+Privacy Policy
+==============
+
+Credentials: The extension saves your credentials locally and may request other permissions (e.g. allowing a website to use a credential) using a secondary prompt. Your credentials will only be used to perform API requests to the requested service respectively (Plex, CouchPotato, Medusa, Ombi, Radarr, Sonarr, and/or Watcher). These credentials will never be shared with outside sources without express permission (i.e. a prompt will appear).
+
+Sharing: To find out if the media is already available on Plex (or other services), the extension may send the title and year, and/or any IDs provided to the service.
+
+Usage/Stats: The extension will not collect any information about your usage.
diff --git a/README.md b/README.md
index ce94d60..acaefb8 100644
--- a/README.md
+++ b/README.md
@@ -67,7 +67,7 @@ The sites used as search engines (IMDb, TMDb, and TVDb) will automatically creat
1. [Movieo](http://movieo.me/)
2. [IMDb](http://imdb.com/)
-3. [Trakt.tv](https://trakt.tv/)
+3. [Trakt](https://trakt.tv/)
4. [Letterboxd](https://letterboxd.com/)
5. [GoStream](https://gostream.site/)1
6. [TV Maze](http://www.tvmaze.com/)
@@ -105,6 +105,9 @@ The sites used as search engines (IMDb, TMDb, and TVDb) will automatically creat
38. [Free Movies Cinema](https://freemoviescinema.com/)6
39. [SnagFilms](https://snagfilms.com/)6
40. [Fox Searchlight](http://foxsearchlight.com/)6
+41. [Verizon](https://tv.verizon.com/)5
+42. [Tubi](https://tubitv.com/)
+43. [Plex](https://app.plex.tv/)
@@ -123,13 +126,15 @@ The sites used as search engines (IMDb, TMDb, and TVDb) will automatically creat
### Install the extension (Store)
-
-
+
+
+
### Install the source code (ZIP)
-
-
+
+
+
## Requirements
diff --git a/badge.chrome.png b/badge.crx.png
similarity index 100%
rename from badge.chrome.png
rename to badge.crx.png
diff --git a/badge.firefox.png b/badge.moz.png
similarity index 100%
rename from badge.firefox.png
rename to badge.moz.png
diff --git a/source.chrome.png b/badge.src.crx.png
similarity index 97%
rename from source.chrome.png
rename to badge.src.crx.png
index 65304ab..013a39c 100644
Binary files a/source.chrome.png and b/badge.src.crx.png differ
diff --git a/source.firefox.png b/badge.src.moz.png
similarity index 100%
rename from source.firefox.png
rename to badge.src.moz.png
diff --git a/badge.src.win.png b/badge.src.win.png
new file mode 100644
index 0000000..b033042
Binary files /dev/null and b/badge.src.win.png differ
diff --git a/badge.win.png b/badge.win.png
new file mode 100644
index 0000000..9328020
Binary files /dev/null and b/badge.win.png differ
diff --git a/example.png b/example.png
index 6047397..99268a1 100644
Binary files a/example.png and b/example.png differ
diff --git a/moz/manifest.json b/moz/manifest.json
index 992f9f7..4675758 100644
--- a/moz/manifest.json
+++ b/moz/manifest.json
@@ -4,7 +4,7 @@
"homepage_url": "https://github.com/SpaceK33z/web-to-plex/",
"manifest_version": 2,
- "version": "4.1.1.7",
+ "version": "4.1.1.8",
"browser_specific_settings": {
"gecko": {
"id": "mink.cbos@gmail.com",
@@ -24,7 +24,6 @@
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
"content_scripts": [
- // Allow media downloads
{
"matches": [
"*://*.openload.co/*", "*://*.oload.co/*",
@@ -70,7 +69,6 @@
"all_frames": true
},
- // The sites
{
"matches": ["*://*.movieo.me/*"],
"js": ["history-hack.js", "utils.js", "movieo$.js"]
diff --git a/moz/options.css b/moz/options.css
index bc3ac0b..048d393 100644
--- a/moz/options.css
+++ b/moz/options.css
@@ -104,6 +104,30 @@ article {
z-index: 3;
}
+.h1 {
+ font-size: 300%;
+}
+
+.h2 {
+ font-size: 200%;
+}
+
+.h3 {
+ font-size: 150%;
+}
+
+.h4 {
+ font-size: 100%;
+}
+
+.h5 {
+ font-size: 75%;
+}
+
+.h6 {
+ font-size: 50%;
+}
+
section {
display: block !important;
margin-bottom: 20px;
@@ -524,13 +548,26 @@ span.checkbox {
opacity: 0.25;
}
-[white] {
+[white], .checkbox[white]::before {
color: #fff !important;
}
+.checkbox[white] input:checked + label {
+ background: #fff !important;
+}
-[orange] {
+[orange], .checkbox[orange]::before {
color: #cc7b19 !important;
}
+.checkbox[orange] input:checked + label {
+ background: #cc7b19 !important;
+}
+
+[blue], .checkbox[blue]::before {
+ color: #197bcc !important;
+}
+.checkbox[blue] input:checked + label {
+ background: #197bcc !important;
+}
input[type="range"] {
appearance: none;
diff --git a/moz/options.html b/moz/options.html
index 695ece5..40854da 100644
--- a/moz/options.html
+++ b/moz/options.html
@@ -330,7 +330,7 @@
Login (saved)
- This information will be used for
Basic Access Authentication only. This will allow the extension to ask Couchpotato for your list of videos, or to add to your list of videos.
+ This information will be used for
Basic Access Authentication only. This will allow the extension to ask Couchpotato for your list of films, or to add to your list of films.
+
Configuration Data (Copy/Paste)
-
- Use this for testing purposes only, as it may contain saved usernames and/or passwords.
-
+
+ Use this for testing purposes only, as it may contain saved usernames and/or passwords.
+
+ You cannot use this feature with Data Compression enabled.
+
Generate Data
Restore Data
diff --git a/moz/options.js b/moz/options.js
index 727a5f6..f81bf87 100644
--- a/moz/options.js
+++ b/moz/options.js
@@ -14,8 +14,8 @@ if(chrome.runtime.lastError)
// FireFox doesn't support sync storage.
const storage = (chrome.storage.sync || chrome.storage.local),
- $ = (selector, all) => (all? document.querySelectorAll(selector): document.querySelector(selector)),
- $$ = (selector, all) => (all? [...$('display').querySelectorAll(selector)]: $('display').querySelector(selector)),
+ $ = top.$ = (selector, all) => (all? [...document.querySelectorAll(selector)]: document.querySelector(selector)),
+ $$ = top.$$ = (selector, all) => (all? [...$('display').querySelectorAll(selector)]: $('display').querySelector(selector)),
__servers__ = $('[data-option="preferredServer"]'),
__sickBeard_qualityProfile__ = $(`[data-option="sickBeardQualityProfileId"]`),
__sickBeard_storagePath__ = $(`[data-option="sickBeardStoragePath"]`),
@@ -447,12 +447,29 @@ function LoadingAnimation(state = false) {
$('display').setAttribute('loading', state);
}
-function load(name) {
- return JSON.parse(localStorage.getItem(btoa(name)));
+function load(name, decompress_data = false) {
+ let options = JSON.stringify(getOptionValues()),
+ data;
+
+ name = btoa(name);
+ data = localStorage.getItem(name);
+
+ if(decompress_data)
+ data = iBWT(decompress(data));
+
+ return JSON.parse(data);
}
-function save(name, data) {
- return localStorage.setItem(btoa(name), JSON.stringify(data));
+function save(name, data, compress_data = false) {
+ let options = JSON.stringify(getOptionValues());
+
+ name = btoa(name);
+ data = JSON.stringify(data);
+
+ if(compress_data)
+ data = compress(BWT(data));
+
+ return localStorage.setItem(name, data);
}
function getServers(plexToken) {
@@ -541,12 +558,13 @@ function performPlexTest({ ServerID, event }) {
__save__.disabled = false;
teststatus.classList = true;
- (servers = [{ sourceTitle: 'GitHub', clientIdentifier: '', name: 'No Plex Server' }, ...servers]).forEach(server => {
+ (servers = [{ sourceTitle: 'GitHub', clientIdentifier: '', name: 'No Server', notice: 'This will not connect to any Plex servers' }, ...servers]).forEach(server => {
let $option = document.createElement('option'),
source = server.sourceTitle;
$option.value = server.clientIdentifier;
$option.textContent = `${ server.name }${ source ? ` \u2014 ${ source }` : '' }`;
+ $option.title = server.notice || (source? `"${ server.sourceTitle }" owns this server`: '');
__servers__.appendChild($option);
});
@@ -595,8 +613,8 @@ function getOptionValues() {
let _c = JSON.stringify(__caught),
_t = JSON.stringify(__theme);
- $('[data-option="__caught"i]').value = options.__caught = (COM? compress(_c): _c);
- $('[data-option="__theme"i]').value = options.__theme = (COM? compress(_t): _t);
+ $('[data-option="__caught"i]').value = options.__caught = (COM? compress(BWT(_c)): _c);
+ $('[data-option="__theme"i]').value = options.__theme = (COM? compress(BWT(_t)): _t);
return options;
}
@@ -2003,6 +2021,16 @@ $('[id^="builtin_"]', true)
})
);
+addListener($('#all-builtin'), 'click', event => {
+ let self = traverse(event.target, element => element == $('#all-builtin'), true),
+ checked = self.checked;
+
+ $(`[id^="builtin"]${ checked? ':not(:checked)': ':checked' }`, true)
+ .forEach(element => element.checked = checked);
+
+ Recall.CountEnabledSites();
+});
+
// Plugins and their links
let plugins = {
'Indomovie': ['https://indomovietv.club/', 'https://indomovietv.org/', 'https://indomovietv.net/'],
@@ -2104,6 +2132,16 @@ $('[id^="plugin_"]', true)
})
);
+addListener($('#all-plugin'), 'click', event => {
+ let self = traverse(event.target, element => element == $('#all-plugin'), true),
+ checked = self.checked;
+
+ $(`[id^="plugin"]${ checked? ':not(:checked)': ':checked' }`, true)
+ .forEach(element => element.checked = checked);
+
+ Recall.CountEnabledSites();
+});
+
let empty = () => {};
document.addEventListener('DOMContentLoaded', restoreOptions);
@@ -2211,9 +2249,9 @@ $('.checkbox', true)
for(let name in options)
if(/^__/.test(name)) {
if(!self.checked)
- options[name] = compress(options[name]);
+ options[name] = compress(BWT(options[name]));
else
- options[name] = decompress(options[name]);
+ options[name] = iBWT(decompress(options[name]));
}
break;
@@ -2452,17 +2490,15 @@ let Recall = {
/* Counting sites that are in use */
Recall['@auto'].CountEnabledSites = () =>
[...$('[counter-for="sites"i]', true)].map(e => {
- let b = $('[id^="builtin_"]', true),
- p = $('[id^="plugin_"]', true),
- b_all = b.length,
- p_all = p.length,
- o = Object.filter(getOptionValues(), k => /^(built|plug)in_/i.test(k)),
- bo = Object.filter(o, k => /^built/i.test(k)),
- b_on = (Object.values(bo).filter(v => v === true)).length,
- po = Object.filter(o, k => /^plug/i.test(k)),
- p_on = (Object.values(po).filter(v => v === true)).length;
-
- e.innerHTML = `${ (b_on + p_on) }/${ (b_all + p_all) }`
+ let b_all = $('[id^="builtin_"]', true),
+ p_all = $('[id^="plugin_"]', true),
+ b_on = b_all.filter(e => e.checked).length,
+ p_on = p_all.filter(e => e.checked).length;
+
+ e.innerHTML = `${ (b_on + p_on) }/${ (b_all.length + p_all.length) }`
+
+ $('#all-builtin').checked = b_all.length == b_on;
+ $('#all-plugin').checked = p_all.length == p_on;
});
/* Setting the DEV badge */
@@ -2553,6 +2589,58 @@ for(let func in Recall) {
}
}
+/* BWT Sorting Algorithm */
+function BWT(string = '') {
+ if(/^[\x32]*$/.test(string))
+ return '';
+
+ let _a = `\u0001${ string }`,
+ _b = `\u0001${ string }\u0001${ string }`,
+ p_ = [];
+
+ for(let i = 0; i < _a.length; i++)
+ p_.push(_b.slice(i, _a.length + i));
+
+ p_ = p_.sort();
+
+ return p_.map(P => P.slice(-1)[0]).join('');
+}
+
+/* BWT Desorting Algorithm */
+function iBWT(string = '') {
+ if(/^[\x32]*$/.test(string))
+ return '';
+
+ let a = string.split('');
+
+ let O = q => {
+ let x = 0;
+ for(let i = 0; i < a.length; i++)
+ if(a[i] < q)
+ x++;
+ return x;
+ };
+
+ let C = (n, q) => {
+ let x = 0;
+ for(let i = 0; i < n; i++)
+ if(a[i] === q)
+ x++;
+ return x;
+ };
+
+ let b = 0,
+ c = '',
+ d = a.length + 1;
+
+ while(a[b] !== '\u0001' && d--) {
+ c = a[b] + c;
+ b = O(a[b]) + C(b, a[b]);
+ }
+
+ return c;
+}
+
/* LZW Compression Algorithm */
function compress(string = '') {
let dictionary = {},
diff --git a/moz/utils.js b/moz/utils.js
index 09104cb..fee5c0d 100644
--- a/moz/utils.js
+++ b/moz/utils.js
@@ -870,7 +870,7 @@ let configuration, init, Update;
COMPRESS = options.UseLZW;
CAUGHT = options.__caught;
- CAUGHT = JSON.parse(COMPRESS? decompress(CAUGHT): CAUGHT);
+ CAUGHT = JSON.parse(COMPRESS? iBWT(decompress(CAUGHT)): CAUGHT);
CAUGHT.bump = async(ids) => {
bumping:
for(let id in ids) {
@@ -890,7 +890,7 @@ let configuration, init, Update;
let __caught = JSON.stringify(CAUGHT);
- __caught = (COMPRESS? compress(__caught): __caught);
+ __caught = (COMPRESS? compress(BWT(__caught)): __caught);
await UTILS_STORAGE.set({ __caught }, () => configuration.__caught = __caught);
};
@@ -1990,7 +1990,7 @@ let configuration, init, Update;
COMPRESS = options.UseLZW;
CAUGHT = __CONFIG__.__caught;
- CAUGHT = JSON.parse(COMPRESS? decompress(CAUGHT): CAUGHT);
+ CAUGHT = JSON.parse(COMPRESS? iBWT(decompress(CAUGHT)): CAUGHT);
browser.runtime.sendMessage({
type: 'PUSH_SICKBEARD',
@@ -2046,7 +2046,7 @@ let configuration, init, Update;
let { __theme } = __CONFIG__;
- let ThemeClasses = JSON.parse(COMPRESS? decompress(__theme): __theme),
+ let ThemeClasses = JSON.parse(COMPRESS? iBWT(decompress(__theme)): __theme),
HeaderClasses = [],
ParsedAttributes = {};
@@ -2758,6 +2758,57 @@ let configuration, init, Update;
})(new Date);
/* Helpers */
+/* BWT Sorting Algorithm */
+function BWT(string = '') {
+ if(/^[\x32]*$/.test(string))
+ return '';
+
+ let _a = `\u0001${ string }`,
+ _b = `\u0001${ string }\u0001${ string }`,
+ p_ = [];
+
+ for(let i = 0; i < _a.length; i++)
+ p_.push(_b.slice(i, _a.length + i));
+
+ p_ = p_.sort();
+
+ return p_.map(P => P.slice(-1)[0]).join('');
+}
+
+/* BWT Desorting Algorithm */
+function iBWT(string = '') {
+ if(/^[\x32]*$/.test(string))
+ return '';
+
+ let a = string.split('');
+
+ let O = q => {
+ let x = 0;
+ for(let i = 0; i < a.length; i++)
+ if(a[i] < q)
+ x++;
+ return x;
+ };
+
+ let C = (n, q) => {
+ let x = 0;
+ for(let i = 0; i < n; i++)
+ if(a[i] === q)
+ x++;
+ return x;
+ };
+
+ let b = 0,
+ c = '',
+ d = a.length + 1;
+
+ while(a[b] !== '\u0001' && d--) {
+ c = a[b] + c;
+ b = O(a[b]) + C(b, a[b]);
+ }
+
+ return c;
+}
/* LZW Compression Algorithm */
function compress(string = '') {
diff --git a/moz/webtoplex.js b/moz/webtoplex.js
index 75fef27..a20f9af 100644
--- a/moz/webtoplex.js
+++ b/moz/webtoplex.js
@@ -1,5 +1,5 @@
// optional
-// "Web to Plex" requires: api, token
+// "Web to Plex" requires: token
// 'Friendly Name' requires permissions...
let script = {
diff --git a/src/cloud/webtoplex.js b/src/cloud/webtoplex.js
index 75fef27..a20f9af 100644
--- a/src/cloud/webtoplex.js
+++ b/src/cloud/webtoplex.js
@@ -1,5 +1,5 @@
// optional
-// "Web to Plex" requires: api, token
+// "Web to Plex" requires: token
// 'Friendly Name' requires permissions...
let script = {
diff --git a/src/manifest.json b/src/manifest.json
index 0bfa0e4..2d62587 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -1,12 +1,10 @@
{
- "update_url": "https://ephellon.github.com/web.to.plex/update.xml",
-
"name": "Web to Plex",
"description": "Adds a button on various movie & TV show sites to open the item in Plex, or send to your designated NZB manager for download.",
"homepage_url": "https://github.com/SpaceK33z/web-to-plex/",
"manifest_version": 2,
- "version": "4.1.1.7",
+ "version": "4.1.1.8",
"icons": {
"16": "img/16.png",
@@ -20,7 +18,6 @@
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
"content_scripts": [
- // Allows media downloads
{
"matches": [
"*://*.openload.co/*", "*://*.oload.co/*",
@@ -66,7 +63,6 @@
"all_frames": true
},
- // The sites
{
"matches": ["*://*.movieo.me/*"],
"js": ["history-hack.js", "utils.js", "sites/movieo/index.js"],
diff --git a/src/options/index.css b/src/options/index.css
index 270267c..f264ee0 100644
--- a/src/options/index.css
+++ b/src/options/index.css
@@ -103,6 +103,30 @@ article {
z-index: 3;
}
+.h1 {
+ font-size: 300%;
+}
+
+.h2 {
+ font-size: 200%;
+}
+
+.h3 {
+ font-size: 150%;
+}
+
+.h4 {
+ font-size: 100%;
+}
+
+.h5 {
+ font-size: 75%;
+}
+
+.h6 {
+ font-size: 50%;
+}
+
section {
display: block !important;
margin-bottom: 20px;
@@ -529,13 +553,26 @@ span.checkbox {
opacity: 0.25;
}
-[white] {
+[white], .checkbox[white]::before {
color: #fff !important;
}
+.checkbox[white] input:checked + label {
+ background: #fff !important;
+}
-[orange] {
+[orange], .checkbox[orange]::before {
color: #cc7b19 !important;
}
+.checkbox[orange] input:checked + label {
+ background: #cc7b19 !important;
+}
+
+[blue], .checkbox[blue]::before {
+ color: #197bcc !important;
+}
+.checkbox[blue] input:checked + label {
+ background: #197bcc !important;
+}
input[type="range"] {
appearance: none;
diff --git a/src/options/index.html b/src/options/index.html
index d2a9f6a..621e084 100644
--- a/src/options/index.html
+++ b/src/options/index.html
@@ -330,7 +330,7 @@ Login (saved)
- This information will be used for
Basic Access Authentication only. This will allow the extension to ask Couchpotato for your list of videos, or to add to your list of videos.
+ This information will be used for
Basic Access Authentication only. This will allow the extension to ask Couchpotato for your list of films, or to add to your list of films.
+
@@ -839,6 +842,8 @@ Configuration Data (Copy/Paste)
Use this for testing purposes only, as it may contain saved usernames and/or passwords.
+
+ You cannot use this feature with Data Compression enabled.
Generate Data
diff --git a/src/options/index.js b/src/options/index.js
index 9e1f8a2..76725f3 100644
--- a/src/options/index.js
+++ b/src/options/index.js
@@ -14,8 +14,8 @@ if(chrome.runtime.lastError)
// FireFox doesn't support sync storage.
const storage = (chrome.storage.sync || chrome.storage.local),
- $ = (selector, all) => (all? [...document.querySelectorAll(selector)]: document.querySelector(selector)),
- $$ = (selector, all) => (all? [...$('display').querySelectorAll(selector)]: $('display').querySelector(selector)),
+ $ = top.$ = (selector, all) => (all? [...document.querySelectorAll(selector)]: document.querySelector(selector)),
+ $$ = top.$$ = (selector, all) => (all? [...$('display').querySelectorAll(selector)]: $('display').querySelector(selector)),
__servers__ = $('[data-option="preferredServer"]'),
__sickBeard_qualityProfile__ = $(`[data-option="sickBeardQualityProfileId"]`),
__sickBeard_storagePath__ = $(`[data-option="sickBeardStoragePath"]`),
@@ -456,7 +456,7 @@ function load(name, decompress_data = false) {
data = localStorage.getItem(name);
if(decompress_data)
- data = decompress(data);
+ data = iBWT(decompress(data));
return JSON.parse(data);
}
@@ -468,7 +468,7 @@ function save(name, data, compress_data = false) {
data = JSON.stringify(data);
if(compress_data)
- data = compress(data);
+ data = compress(BWT(data));
return localStorage.setItem(name, data);
}
@@ -564,12 +564,13 @@ function performPlexTest({ ServerID, event }) {
__save__.disabled = false;
teststatus.classList = true;
- (servers = [{ sourceTitle: 'GitHub', clientIdentifier: '', name: 'No Plex Server' }, ...servers]).forEach(server => {
+ (servers = [{ sourceTitle: 'GitHub', clientIdentifier: '', name: 'No Server', notice: 'This will not connect to any Plex servers' }, ...servers]).forEach(server => {
let $option = document.createElement('option'),
source = server.sourceTitle;
$option.value = server.clientIdentifier;
$option.textContent = `${ server.name }${ source ? ` \u2014 ${ source }` : '' }`;
+ $option.title = server.notice || (source? `"${ server.sourceTitle }" owns this server`: '');
__servers__.appendChild($option);
});
@@ -618,8 +619,8 @@ function getOptionValues() {
let _c = JSON.stringify(__caught),
_t = JSON.stringify(__theme);
- $('[data-option="__caught"i]').value = options.__caught = (COM? compress(_c): _c);
- $('[data-option="__theme"i]').value = options.__theme = (COM? compress(_t): _t);
+ $('[data-option="__caught"i]').value = options.__caught = (COM? compress(BWT(_c)): _c);
+ $('[data-option="__theme"i]').value = options.__theme = (COM? compress(BWT(_t)): _t);
return options;
}
@@ -2026,6 +2027,16 @@ $('[id^="builtin_"]', true)
})
);
+addListener($('#all-builtin'), 'click', event => {
+ let self = traverse(event.target, element => element == $('#all-builtin'), true),
+ checked = self.checked;
+
+ $(`[id^="builtin"]${ checked? ':not(:checked)': ':checked' }`, true)
+ .forEach(element => element.checked = checked);
+
+ Recall.CountEnabledSites();
+});
+
// Plugins and their links
let plugins = {
'Indomovie': ['https://indomovietv.club/', 'https://indomovietv.org/', 'https://indomovietv.net/'],
@@ -2127,6 +2138,16 @@ $('[id^="plugin_"]', true)
})
);
+addListener($('#all-plugin'), 'click', event => {
+ let self = traverse(event.target, element => element == $('#all-plugin'), true),
+ checked = self.checked;
+
+ $(`[id^="plugin"]${ checked? ':not(:checked)': ':checked' }`, true)
+ .forEach(element => element.checked = checked);
+
+ Recall.CountEnabledSites();
+});
+
let empty = () => {};
document.addEventListener('DOMContentLoaded', restoreOptions);
@@ -2228,9 +2249,9 @@ $('.checkbox', true)
for(let name in options)
if(/^__/.test(name)) {
if(!self.checked)
- options[name] = compress(options[name]);
+ options[name] = compress(BWT(options[name]));
else
- options[name] = decompress(options[name]);
+ options[name] = iBWT(decompress(options[name]));
}
break;
@@ -2469,17 +2490,15 @@ let Recall = {
/* Counting sites that are in use */
Recall['@auto'].CountEnabledSites = () =>
[...$('[counter-for="sites"i]', true)].map(e => {
- let b = $('[id^="builtin_"]', true),
- p = $('[id^="plugin_"]', true),
- b_all = b.length,
- p_all = p.length,
- o = Object.filter(getOptionValues(), k => /^(built|plug)in_/i.test(k)),
- bo = Object.filter(o, k => /^built/i.test(k)),
- b_on = (Object.values(bo).filter(v => v === true)).length,
- po = Object.filter(o, k => /^plug/i.test(k)),
- p_on = (Object.values(po).filter(v => v === true)).length;
-
- e.innerHTML = `${ (b_on + p_on) }/${ (b_all + p_all) }`
+ let b_all = $('[id^="builtin_"]', true),
+ p_all = $('[id^="plugin_"]', true),
+ b_on = b_all.filter(e => e.checked).length,
+ p_on = p_all.filter(e => e.checked).length;
+
+ e.innerHTML = `${ (b_on + p_on) }/${ (b_all.length + p_all.length) }`
+
+ $('#all-builtin').checked = b_all.length == b_on;
+ $('#all-plugin').checked = p_all.length == p_on;
});
/* Setting the DEV badge */
@@ -2488,8 +2507,6 @@ Recall['@auto'].ToggleDeveloprBadge = (state = null) =>
if(state === null)
state = getOptionValues().DeveloperMode;
- console.log(state);
-
if(state)
return e.style.display = 'inline-block';
e.style.display = 'none';
@@ -2572,6 +2589,58 @@ for(let func in Recall) {
}
}
+/* BWT Sorting Algorithm */
+function BWT(string = '') {
+ if(/^[\x32]*$/.test(string))
+ return '';
+
+ let _a = `\u0001${ string }`,
+ _b = `\u0001${ string }\u0001${ string }`,
+ p_ = [];
+
+ for(let i = 0; i < _a.length; i++)
+ p_.push(_b.slice(i, _a.length + i));
+
+ p_ = p_.sort();
+
+ return p_.map(P => P.slice(-1)[0]).join('');
+}
+
+/* BWT Desorting Algorithm */
+function iBWT(string = '') {
+ if(/^[\x32]*$/.test(string))
+ return '';
+
+ let a = string.split('');
+
+ let O = q => {
+ let x = 0;
+ for(let i = 0; i < a.length; i++)
+ if(a[i] < q)
+ x++;
+ return x;
+ };
+
+ let C = (n, q) => {
+ let x = 0;
+ for(let i = 0; i < n; i++)
+ if(a[i] === q)
+ x++;
+ return x;
+ };
+
+ let b = 0,
+ c = '',
+ d = a.length + 1;
+
+ while(a[b] !== '\u0001' && d--) {
+ c = a[b] + c;
+ b = O(a[b]) + C(b, a[b]);
+ }
+
+ return c;
+}
+
/* LZW Compression Algorithm */
function compress(string = '') {
let dictionary = {},
diff --git a/src/utils.js b/src/utils.js
index 2e38b2a..85c0a5a 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -868,7 +868,7 @@ let configuration, init, Update;
COMPRESS = options.UseLZW;
CAUGHT = options.__caught;
- CAUGHT = JSON.parse(COMPRESS? decompress(CAUGHT): CAUGHT);
+ CAUGHT = JSON.parse(COMPRESS? iBWT(decompress(CAUGHT)): CAUGHT);
CAUGHT.bump = async(ids) => {
bumping:
for(let id in ids) {
@@ -888,7 +888,7 @@ let configuration, init, Update;
let __caught = JSON.stringify(CAUGHT);
- __caught = (COMPRESS? compress(__caught): __caught);
+ __caught = (COMPRESS? compress(BWT(__caught)): __caught);
await UTILS_STORAGE.set({ __caught }, () => configuration.__caught = __caught);
};
@@ -1955,7 +1955,7 @@ let configuration, init, Update;
COMPRESS = options.UseLZW;
CAUGHT = __CONFIG__.__caught;
- CAUGHT = JSON.parse(COMPRESS? decompress(CAUGHT): CAUGHT);
+ CAUGHT = JSON.parse(COMPRESS? iBWT(decompress(CAUGHT)): CAUGHT);
chrome.runtime.sendMessage({
type: 'PUSH_SICKBEARD',
@@ -2006,7 +2006,7 @@ let configuration, init, Update;
let { __theme } = __CONFIG__;
- let ThemeClasses = JSON.parse(COMPRESS? decompress(__theme): __theme),
+ let ThemeClasses = JSON.parse(COMPRESS? iBWT(decompress(__theme)): __theme),
HeaderClasses = [],
ParsedAttributes = {};
@@ -2711,6 +2711,57 @@ let configuration, init, Update;
})(new Date);
/* Helpers */
+/* BWT Sorting Algorithm */
+function BWT(string = '') {
+ if(/^[\x32]*$/.test(string))
+ return '';
+
+ let _a = `\u0001${ string }`,
+ _b = `\u0001${ string }\u0001${ string }`,
+ p_ = [];
+
+ for(let i = 0; i < _a.length; i++)
+ p_.push(_b.slice(i, _a.length + i));
+
+ p_ = p_.sort();
+
+ return p_.map(P => P.slice(-1)[0]).join('');
+}
+
+/* BWT Desorting Algorithm */
+function iBWT(string = '') {
+ if(/^[\x32]*$/.test(string))
+ return '';
+
+ let a = string.split('');
+
+ let O = q => {
+ let x = 0;
+ for(let i = 0; i < a.length; i++)
+ if(a[i] < q)
+ x++;
+ return x;
+ };
+
+ let C = (n, q) => {
+ let x = 0;
+ for(let i = 0; i < n; i++)
+ if(a[i] === q)
+ x++;
+ return x;
+ };
+
+ let b = 0,
+ c = '',
+ d = a.length + 1;
+
+ while(a[b] !== '\u0001' && d--) {
+ c = a[b] + c;
+ b = O(a[b]) + C(b, a[b]);
+ }
+
+ return c;
+}
/* LZW Compression Algorithm */
function compress(string = '') {
diff --git a/win.crx b/win.crx
new file mode 100644
index 0000000..59985fc
Binary files /dev/null and b/win.crx differ
diff --git a/win.zip b/win.zip
new file mode 100644
index 0000000..cee1785
Binary files /dev/null and b/win.zip differ
diff --git a/win/background.js b/win/background.js
new file mode 100644
index 0000000..ecd4cde
--- /dev/null
+++ b/win/background.js
@@ -0,0 +1,1052 @@
+/* global chrome */
+let BACKGROUND_DEVELOPER = false;
+
+let external = {},
+ __context_parent__,
+ __context_save_element__,
+ BACKGROUND_TERMINAL =
+ BACKGROUND_DEVELOPER?
+ console:
+ { error: m => m, info: m => m, log: m => m, warn: m => m, group: m => m, groupEnd: m => m };
+
+let date = (new Date),
+ YEAR = date.getFullYear(),
+ MONTH = date.getMonth() + 1,
+ DATE = date.getDate();
+
+let BACKGROUND_STORAGE = chrome.storage.sync || chrome.storage.local;
+let BACKGROUND_CONFIGURATION;
+
+// returns the proper CORS mode of the URL
+let cors = url => ((/^(https|sftp)\b/i.test(url) || /\:(443|22)\b/i.test(url)? '': 'no-') + 'cors');
+
+// Create a Crypto-Key
+// new Key(number:integer, string:symbol) -> string
+class Key {
+ constructor(length = 8, symbol = '') {
+ let values = [];
+
+ window.crypto.getRandomValues(new Uint32Array(16)).forEach((value, index, array) => values.push(value.toString(36)));
+
+ return this.length = length, this.value = values.join(symbol);
+ }
+
+ rehash(length, symbol) {
+ if(length <= 0)
+ length = this.length;
+
+ return this.value = new Key(length, symbol);
+ }
+}
+
+// Session instances
+let SessionKey = new Key(16), // create a session key
+ SessionState = false; // has this been run already?
+
+// Generate request headers (for fetches)
+// new Headers({username, password}) -> object
+class Headers {
+ constructor(Authorization) {
+ let headers = { Accept: 'application/json' };
+
+ if(!Authorization)
+ return headers;
+
+ return {
+ Authorization: `Basic ${ btoa(`${ Authorization.username }:${ Authorization.password }`) }`,
+ ...headers
+ };
+ }
+}
+
+// Change the badge status
+// ChangeStatus({ MovieOrShowID, MovieOrShowTitle, MovieOrShowType, MovieOrShowIDProvider, MovieOrShowYear, LinkURL, FileType, FilePath }) -> undefined
+function ChangeStatus({ ITEM_ID, ITEM_TITLE, ITEM_TYPE, ID_PROVIDER, ITEM_YEAR, ITEM_URL = '', FILE_TYPE = '', FILE_PATH }) {
+
+ let FILE_TITLE = ITEM_TITLE.replace(/\-/g, ' ').replace(/[\s\:]{2,}/g, ' - ').replace(/[^\w\s\-\']+/g, ''),
+ // File friendly title
+ SEARCH_TITLE = ITEM_TITLE.replace(/[\-\s]+/g, '-').replace(/\s*&\s*/g, ' and ').replace(/[^\w\-\'\*\#]+/g, ''),
+ // Search friendly title
+ SEARCH_PROVIDER = /\b(tv|show|series)\b/i.test(ITEM_TYPE)? 'GG': /^im/i.test(ID_PROVIDER)? 'VO': /^tm/i.test(ID_PROVIDER)? 'GX': 'GG';
+
+ ITEM_ID = (ITEM_ID && !/^tt$/i.test(ITEM_ID)? ITEM_ID: '') + '';
+ ITEM_ID = ITEM_ID.replace(/^.*\b(tt\d+)\b.*$/, '$1').replace(/^.*\bid=(\d+)\b.*$/, '$1').replace(/^.*(?:movie|tv|(?:tv-?)?(?:shows?|series|episodes?))\/(\d+).*$/, '$1');
+
+ external = { ...external, ID_PROVIDER, ITEM_ID, ITEM_TITLE, ITEM_YEAR, ITEM_URL, ITEM_TYPE, SEARCH_PROVIDER, SEARCH_TITLE, FILE_PATH, FILE_TITLE, FILE_TYPE };
+
+ chrome.browserAction.setBadgeText({
+ text: ID_PROVIDER
+ });
+
+ chrome.browserAction.setBadgeBackgroundColor({
+ color: (ITEM_ID? '#f45a26': '#666666')
+ });
+
+ chrome.contextMenus.update('W2P', {
+ title: `Find "${ ITEM_TITLE } (${ ITEM_YEAR || YEAR })"`
+ });
+
+ for(let databases = 'IM TM TV'.split(' '), length = databases.length, index = 0, database; index < length; index++)
+ chrome.contextMenus.update('W2P-' + (database = databases[index]), {
+ title: (
+ ((ID_PROVIDER == (database += 'Db')) && ITEM_ID)?
+ `Open in ${ database } (${ (+ITEM_ID? '#': '') + ITEM_ID })`:
+ `Find in ${ database }`
+ ),
+ checked: false
+ });
+
+ chrome.contextMenus.update('W2P-XX', {
+ title: `Find on ${ (SEARCH_PROVIDER == 'VO'? 'Vumoo': SEARCH_PROVIDER == 'GX'? 'GoStream': 'Google') }`,
+ checked: false
+ });
+}
+
+// get the saved options
+function getConfiguration() {
+ return new Promise((resolve, reject) => {
+ function handleConfiguration(options) {
+ if((!options.plexToken || !options.servers) && !options.IGNORE_PLEX)
+ return reject(new Error('Required options are missing')),
+ null;
+
+ let server, o;
+
+ if(!options.IGNORE_PLEX) {
+ // For now we support only one Plex server, but the options already
+ // allow multiple for easy migration in the future.
+ server = options.servers[0];
+ o = {
+ server: {
+ ...server,
+ // Compatibility for users who have not updated their settings yet.
+ connections: server.connections || [{ uri: server.url }]
+ },
+ ...options
+ };
+ } else {
+ o = options;
+ }
+
+ resolve(o);
+ }
+
+ BACKGROUND_STORAGE.get(null, options => {
+ if(chrome.runtime.lastError)
+ chrome.storage.local.get(null, handleOptions);
+ else
+ handleConfiguration(options);
+ });
+ });
+}
+
+// self explanatory, returns an object; sets the configuration variable
+function parseConfiguration() {
+ return getConfiguration().then(options => {
+ if((BACKGROUND_DEVELOPER = options.DeveloperMode) && !parseConfiguration.gotConfig) {
+ parseConfiguration.gotConfig = true;
+ BACKGROUND_TERMINAL =
+ BACKGROUND_DEVELOPER?
+ console:
+ { error: m => m, info: m => m, log: m => m, warn: m => m, group: m => m, groupEnd: m => m };
+
+ BACKGROUND_TERMINAL.warn(`BACKGROUND_DEVELOPER: ${BACKGROUND_DEVELOPER}`);
+ }
+
+ return options;
+ }, error => { throw error });
+}
+
+function load(name, private) {
+ return JSON.parse((private && sessionStorage? sessionStorage: localStorage).getItem(btoa(name)));
+}
+
+function save(name, data, private) {
+ return (private && sessionStorage? sessionStorage: localStorage).setItem(btoa(name), JSON.stringify(data));
+}
+
+async function UpdateConfiguration(force_update = false) {
+ let configuration = load('configuration');
+
+ if(force_update || configuration === null || configuration === undefined)
+ BACKGROUND_CONFIGURATION = await parseConfiguration();
+ else
+ BACKGROUND_CONFIGURATION = configuration;
+
+ save('configuration', BACKGROUND_CONFIGURATION);
+};
+
+UpdateConfiguration();
+
+/** CouchPotato - Movies **/
+// At this point you might want to think, WHY would you want to do
+// these requests in a background page instead of the content script?
+// This is because Movieo is served over HTTPS, so it won't accept requests to
+// HTTP servers. Unfortunately, many people use CouchPotato over HTTP.
+function Open_CouchPotato(request, sendResponse) {
+ fetch(`${ request.url }?id=${ request.imdbId }`, {
+ headers: new Headers(request.basicAuth),
+ mode: cors(request.url)
+ })
+ .then(response => response.json())
+ .then(json => {
+ sendResponse({ success, status: (success? json.media.status: null) });
+ })
+ .catch(error => {
+ sendResponse({ error: String(error), location: '@0B: Open_CouchPotato' });
+ });
+}
+
+function Push_CouchPotato(request, sendResponse) {
+ fetch(`${ request.url }?identifier=${ request.imdbId }`, {
+ headers: new Headers(request.basicAuth),
+ mode: cors(request.url)
+ })
+ .then(response => response.json())
+ .catch(error => sendResponse({ error: 'Item not found', location: '@0B: Push_CouchPotato => fetch.then.catch', silent: true }))
+ .then(response => {
+ sendResponse({ success: response.success });
+ })
+ .catch(error => {
+ sendResponse({ error: String(error) , location: '@0B: Push_CouchPotato'});
+ });
+}
+
+/** Watcher - Movies **/
+function Push_Watcher(request, sendResponse) {
+ let headers = {
+ 'Content-Type': 'application/json',
+ 'X-Api-Key': request.token,
+ ...(new Headers(request.basicAuth))
+ },
+ id = (/^(tt)?$/.test(request.imdbId)? request.tmdbId: request.imdbId),
+ // if the IMDbID is empty, jump to the TMDbID
+ query = (/^tt\d+$/i.test(id)? 'imdbid': /^\d+$/.test(id)? 'tmdbid': (id = encodeURI(`${request.title} ${request.year}`), 'term')),
+ // if the IMDbID is empty, use "&tmdbid={ id }"
+ // if the IMDbID isn't empty, use "&imdbid={ id }"
+ // otherwise, use "&term={ title } { year }"
+ debug = { headers, query, request };
+ // setup a stack trace for debugging
+
+ fetch(debug.url = `${ request.url }?apikey=${ request.token }&mode=addmovie&${ query }=${ id }`)
+ .then(response => response.json())
+ .catch(error => sendResponse({ error: 'Movie not found', location: '@0B: Push_Watcher => fetch.then.catch', silent: true }))
+ .then(response => {
+ if((response.response + "") == "true")
+ return sendResponse({
+ success: `Added to Watcher (${ request.StoragePath })`
+ });
+
+ throw new Error(response.error);
+ })
+ .catch(error => {
+ sendResponse({
+ error: String(error),
+ location: `@0B: Push_Watcher => fetch("${ request.url }", { headers }).catch(error => { sendResponse })`,
+ debug
+ });
+ });
+}
+
+/** Radarr - Movies **/
+function Push_Radarr(request, sendResponse) {
+ let headers = {
+ 'Content-Type': 'application/json',
+ 'X-Api-Key': request.token,
+ ...(new Headers(request.basicAuth))
+ },
+ id = (/^(tt)?$/.test(request.imdbId)? request.tmdbId: request.imdbId),
+ // if the IMDbID is empty, jump to the TMDbID
+ query = (/^tt\d+$/i.test(id)? 'imdb?imdbid': /^\d+$/.test(id)? 'tmdb?tmdbid': (id = encodeURI(`${request.title} ${request.year}`), 'term')),
+ // if the IMDbID is empty, use "/tmdb?tmdbid={ id }"
+ // if the IMDbID isn't empty, use "/imdb?imdbid={ id }"
+ // otherwise, use "&term={ title } { year }"
+ debug = { headers, query, request };
+ // setup a stack trace for debugging
+
+ fetch(debug.url = `${ request.url }lookup/${ query }=${ id }&apikey=${ request.token }`)
+ .then(response => response.json())
+ .catch(error => sendResponse({ error: 'Movie not found', location: '@0B: Push_Radarr => fetch.then.catch', silent: true }))
+ .then(data => {
+ let body,
+ // Monitor, search, and download movie ASAP
+ props = {
+ monitored: true,
+ minimumAvailability: 'preDB',
+ qualityProfileId: request.QualityID,
+ rootFolderPath: request.StoragePath,
+ addOptions: {
+ searchForMovie: true
+ }
+ };
+
+ if(!(data instanceof Array) && !data.length && !data.title) {
+ throw new Error('Movie not found');
+ } else if(data.length) {
+ body = {
+ ...data[0],
+ ...props
+ };
+ } else if(data.title) {
+ body = {
+ ...data,
+ ...props
+ };
+ }
+
+ BACKGROUND_TERMINAL.group('Generated URL');
+ BACKGROUND_TERMINAL.log('URL', request.url);
+ BACKGROUND_TERMINAL.log('Head', headers);
+ BACKGROUND_TERMINAL.log('Body', body);
+ BACKGROUND_TERMINAL.groupEnd();
+
+ return debug.body = body;
+ })
+ .then(body => {
+ return fetch(`${ request.url }?apikey=${ request.token }`, debug.requestHeaders = {
+ method: 'POST',
+ mode: cors(request.url),
+ body: JSON.stringify(body),
+ headers
+ });
+ })
+ .then(response => response.text())
+ .then(data => {
+ debug.data =
+ data = JSON.parse(data || `{"path":"${ request.StoragePath.replace(/\\/g, '\\\\') }${ request.title } (${ request.year })"}`);
+
+ if(data && data[0] && data[0].errorMessage) {
+ sendResponse({
+ error: data[0].errorMessage,
+ location: `@0B: Push_Radarr => fetch("${ request.url }", { headers }).then(data => { if })`,
+ debug
+ });
+ } else if(data && data.path) {
+ sendResponse({
+ success: 'Added to ' + data.path
+ });
+ } else {
+ sendResponse({
+ error: 'Unknown error',
+ location: `@0B: Push_Radarr => fetch("${ request.url }", { headers }).then(data => { else })`,
+ debug
+ });
+ }
+ })
+ .catch(error => {
+ sendResponse({
+ error: String(error),
+ location: `@0B: Push_Radarr => fetch("${ request.url }", { headers }).catch(error => { sendResponse })`,
+ debug
+ });
+ });
+}
+
+/** Sonarr - TV Shows **/
+function Push_Sonarr(request, sendResponse) {
+ let headers = {
+ 'Content-Type': 'application/json',
+ 'X-Api-Key': request.token,
+ ...(new Headers(request.basicAuth))
+ },
+ id = request.tvdbId,
+ query = encodeURIComponent(`tvdb:${ id }`),
+ debug = { headers, query, request };
+ // setup stack trace for debugging
+
+ fetch(debug.url = `${ request.url }lookup?apikey=${ request.token }&term=${ query }`)
+ .then(response => response.json())
+ .catch(error => sendResponse({ error: 'TV Show not found', location: '@0B: Push_Sonarr => fetch.then.catch', silent: true }))
+ .then(data => {
+ if(!(data instanceof Array) || !data.length)
+ throw new Error('TV Show not found');
+
+ // Monitor, search, and download series ASAP
+ let body = {
+ ...data[0],
+ monitored: true,
+ seasonFolder: true,
+ minimumAvailability: 'preDB',
+ qualityProfileId: request.QualityID,
+ rootFolderPath: request.StoragePath,
+ addOptions: {
+ searchForMissingEpisodes: true
+ }
+ };
+
+ BACKGROUND_TERMINAL.group('Generated URL');
+ BACKGROUND_TERMINAL.log('URL', request.url);
+ BACKGROUND_TERMINAL.log('Head', headers);
+ BACKGROUND_TERMINAL.log('Body', body);
+ BACKGROUND_TERMINAL.groupEnd();
+
+ return debug.body = body;
+ })
+ .then(body => {
+ return fetch(`${ request.url }?apikey=${ request.token }`, debug.requestHeaders = {
+ method: 'POST',
+ mode: cors(request.url),
+ body: JSON.stringify(body),
+ headers
+ });
+ })
+ .then(response => response.text())
+ .then(data => {
+ debug.data =
+ data = JSON.parse(data || `{"path":"${ request.StoragePath.replace(/\\/g, '\\\\') }${ request.title } (${ request.year })"}`);
+
+ if(data && data[0] && data[0].errorMessage) {
+ sendResponse({
+ error: data[0].errorMessage,
+ location: `@0B: Push_Sonarr => fetch("${ request.url }", { headers }).then(data => { if })`,
+ debug
+ });
+ } else if(data && data.path) {
+ sendResponse({
+ success: 'Added to ' + data.path
+ });
+ } else {
+ sendResponse({
+ error: 'Unknown error',
+ location: `@0B: Push_Sonarr => fetch("${ request.url }", { headers }).then(data => { else })`,
+ debug
+ });
+ }
+ })
+ .catch(error => {
+ sendResponse({
+ error: String(error),
+ location: `@0B: Push_Sonarr => fetch("${ request.url }", { headers }).catch(error => { sendResponse })`,
+ debug
+ });
+ });
+}
+
+/** Medusa - TV Shows **/
+function Push_Medusa(request, sendResponse) {
+ let headers = {
+ 'Content-Type': 'application/json',
+ 'X-Api-Key': request.token,
+ ...(new Headers(request.basicAuth))
+ },
+ id = request.tvdbId,
+ query = request.title.replace(/\s+/g, '+'),
+ debug = { headers, query, request };
+ // setup stack trace for debugging
+
+ fetch(debug.url = `${ request.root }internal/searchIndexersForShowName?api_key=${ request.token }&indexerId=0&query=${ query }`)
+ .then(response => response.json())
+ .catch(error => sendResponse({ error: 'TV Show not found', location: '@0B: Push_Medusa => fetch.then.catch', silent: true }))
+ .then(data => {
+ data = data.results;
+
+ if(!(data instanceof Array) || !data.length)
+ throw new Error('TV Show not found');
+
+ // Monitor, search, and download series ASAP
+ let body = data[0].join('|');
+
+ BACKGROUND_TERMINAL.group('Generated URL');
+ BACKGROUND_TERMINAL.log('URL', request.url);
+ BACKGROUND_TERMINAL.log('Head', headers);
+ BACKGROUND_TERMINAL.log('Body', body);
+ BACKGROUND_TERMINAL.groupEnd();
+
+ return debug.body = body;
+ })
+ .then(body => {
+ return fetch(`${ request.url }`, debug.requestHeaders = {
+ method: 'POST',
+ mode: cors(request.url),
+ body: JSON.stringify({ id: { tvdb: id } }),
+ headers
+ });
+ })
+ .then(response => response.text())
+ .then(data => {
+ let path = request.StoragePath.replace(/\\?$/, '\\');
+
+ debug.data =
+ data = JSON.parse(data || `{"path":"${ path }${ request.title } (${ request.year })"}`);
+
+ if(data && data.error) {
+ sendResponse({
+ error: data.error,
+ location: `@0B: Push_Medusa => fetch("${ request.url }", { headers }).then(data => { if })`,
+ debug
+ });
+ } else if(data && data.id) {
+ sendResponse({
+ success: `Added to ${ path }${ request.title }(${ request.year })`
+ });
+ } else {
+ sendResponse({
+ error: 'Unknown error',
+ location: `@0B: Push_Medusa => fetch("${ request.url }", { headers }).then(data => { else })`,
+ debug
+ });
+ }
+ })
+ .catch(error => {
+ sendResponse({
+ error: String(error),
+ location: `@0B: Push_Medusa => fetch("${ request.url }", { headers }).catch(error => { sendResponse })`,
+ debug
+ });
+ });
+}
+
+/** Medusa - TV Shows **/
+function addMedusa(request, sendResponse) {
+ let headers = {
+ 'Content-Type': 'application/json',
+ 'X-Api-Key': request.token,
+ ...(new Headers(request.basicAuth))
+ },
+ id = request.tvdbId,
+ query = request.title.replace(/\s+/g, '+'),
+ debug = { headers, query, request };
+ // setup stack trace for debugging
+
+ fetch(debug.url = `${ request.root }internal/searchIndexersForShowName?api_key=${ request.token }&indexerId=0&query=${ query }`)
+ .then(response => response.json())
+ .catch(error => sendResponse({ error: 'TV Show not found', location: 'addMedusa => fetch.then.catch', silent: true }))
+ .then(data => {
+ data = data.results;
+
+ if(!(data instanceof Array) || !data.length)
+ throw new Error('TV Show not found');
+
+ // Monitor, search, and download series ASAP
+ let body = data[0].join('|');
+
+ BACKGROUND_TERMINAL.group('Generated URL');
+ BACKGROUND_TERMINAL.log('URL', request.url);
+ BACKGROUND_TERMINAL.log('Head', headers);
+ BACKGROUND_TERMINAL.log('Body', body);
+ BACKGROUND_TERMINAL.groupEnd();
+
+ return debug.body = body;
+ })
+ .then(body => {
+ return fetch(`${ request.url }`, debug.requestHeaders = {
+ method: 'POST',
+ mode: cors(request.url),
+ body: JSON.stringify({ id: { tvdb: id } }),
+ headers
+ });
+ })
+ .then(response => response.text())
+ .then(data => {
+ let path = request.StoragePath.replace(/\\?$/, '\\');
+
+ debug.data =
+ data = JSON.parse(data || `{"path":"${ path }${ request.title } (${ request.year })"}`);
+
+ if(data && data.error) {
+ sendResponse({
+ error: data.error,
+ location: `addMedusa => fetch("${ request.url }", { headers }).then(data => { if })`,
+ debug
+ });
+ } else if(data && data.id) {
+ sendResponse({
+ success: `Added to ${ path }${ request.title }(${ request.year })`
+ });
+ } else {
+ sendResponse({
+ error: 'Unknown error',
+ location: `addMedusa => fetch("${ request.url }", { headers }).then(data => { else })`,
+ debug
+ });
+ }
+ })
+ .catch(error => {
+ sendResponse({
+ error: String(error),
+ location: `addMedusa => fetch("${ request.url }", { headers }).catch(error => { sendResponse })`,
+ debug
+ });
+ });
+}
+
+/** Sick Beard - TV Shows **/
+function Push_SickBeard(request, sendResponse) {
+ let headers = {
+ 'Content-Type': 'application/json',
+ 'X-Api-Key': request.token,
+ ...(new Headers(request.basicAuth))
+ },
+ id = request.tvdbId,
+ query = `tvdbid=${ id }`,
+ path = (`${ request.StoragePath }\\${ request.title }`).replace(/\\\\/g, '\\'),
+ debug = { headers, query, request };
+ // setup stack trace for debugging
+
+ fetch(debug.url = `${ request.url }?cmd=sb.searchtvdb&${ query }`)
+ .then(response => response.json())
+ .catch(error => sendResponse({ error: 'TV Show not found', location: '@0B: Push_SickBeard => fetch.then.catch', silent: true }))
+ .then(data => {
+ if(!/^success$/i.test(data.result))
+ throw new Error('TV Show not found');
+
+ data = data.data.results;
+
+ // Monitor, search, and download series ASAP
+ let body = formify({
+ tvdbid: id,
+ initial: request.QualityID,
+ location: encodeURIComponent(path),
+ status: 'wanted',
+ });
+
+ BACKGROUND_TERMINAL.group('Generated URL');
+ BACKGROUND_TERMINAL.log('URL', request.url);
+ BACKGROUND_TERMINAL.log('Head', headers);
+ BACKGROUND_TERMINAL.log('Body', body);
+ BACKGROUND_TERMINAL.groupEnd();
+
+ return debug.body = body;
+ })
+ .then(async body => {
+ await fetch(`${ request.url }?cmd=sb.addrootdir&${ body }`);
+
+ return fetch(`${ request.url }?cmd=show.${ request.exists? 'addexisting': 'addnew' }&${ body }`, debug.requestHeaders = {
+ method: 'POST',
+ mode: cors(request.url),
+ // body: JSON.stringify(body),
+ headers
+ });
+ })
+ .then(response => response.text())
+ .then(results => {
+ debug.data =
+ results = JSON.parse(results || `{"data":{},message:"",result:""}`);
+
+ let { data, message, result } = results;
+
+ if(data && !/^success$/i.test(result) && message) {
+ sendResponse({
+ error: message,
+ location: `@0B: Push_SickBeard => fetch("${ request.url }", { headers }).then(results => { if })`,
+ debug
+ });
+ } else if(data && data.path) {
+ sendResponse({
+ success: `Added to ${ request.StoragePath }${ request.title } (${ request.year })`
+ });
+ } else {
+ sendResponse({
+ error: 'Unknown error',
+ location: `@0B: Push_SickBeard => fetch("${ request.url }", { headers }).then(results => { else })`,
+ debug
+ });
+ }
+ })
+ .catch(error => {
+ sendResponse({
+ error: String(error),
+ location: `@0B: Push_SickBeard => fetch("${ request.url }", { headers }).catch(error => { sendResponse })`,
+ debug
+ });
+ });
+}
+
+/** Ombi* - TV Shows/Movies **/
+function Push_Ombi(request, sendResponse) {
+ let headers = {
+ 'Content-Type': 'application/json',
+ 'ApiKey': request.token,
+ ...(new Headers)
+ },
+ type = request.contentType,
+ id = (type == 'movie'? request.tmdbId: request.tvdbId),
+ body = ({ [type == 'movie'? 'theMovieDbId': 'theTvDbId']: id }),
+ debug = { headers, request };
+ // setup stack trace for debugging
+
+ if(request.contentType == 'movie' && (id || null) === null)
+ sendResponse({ error: 'Invalid TMDbID', location: '@0B: Push_Ombi => if', silent: true });
+ else if((id || null) === null)
+ sendResponse({ error: 'Invalid TVDbID', location: '@0B: Push_Ombi => else if', silent: true });
+
+ fetch(debug.url = request.url, {
+ method: 'POST',
+ mode: cors(request.url),
+ body: JSON.stringify(body),
+ headers
+ })
+ .catch(error => sendResponse({ error: `${ type } not found`, location: '@0B: Push_Ombi => fetch.then.catch', silent: true }))
+ .then(response => response.text())
+ .then(data => {
+ debug.data =
+ data = JSON.parse(data);
+
+ if(data && data.isError) {
+ if(/already +been +requested/i.test(data.errorMessage))
+ sendResponse({
+ success: 'Already requested on Ombi'
+ });
+ else
+ sendResponse({
+ error: data.errorMessage,
+ location: `@0B: Push_Ombi => fetch("${ request.url }", { headers }).then(data => { if })`,
+ debug
+ });
+ } else if(data && data.path) {
+ sendResponse({
+ success: 'Added to Ombi'
+ });
+ } else {
+ sendResponse({
+ error: 'Unknown error',
+ location: `@0B: Push_Ombi => fetch("${ request.url }", { headers }).then(data => { else })`,
+ debug
+ });
+ }
+ })
+ .catch(error => {
+ sendResponse({
+ error: String(error),
+ location: `@0B: Push_Ombi => fetch("${ request.url }", { headers }).catch(error => { sendResponse })`,
+ debug
+ });
+ });
+}
+
+// Unfortunately the native Promise.race does not work as you would suspect.
+// If one promise (Plex request) fails, we still want the other requests to continue racing.
+// See https://www.jcore.com/2016/12/18/promise-me-you-wont-use-promise-race/ for an explanation
+function PromiseRace(promises) {
+ if(!~promises.length) {
+ return Promise.reject('Cannot start a race without promises!');
+ }
+
+ // There is no way to know which promise is rejected.
+ // So we map it to a new promise to return the index when it fails
+ let Promises = promises.map((promise, index) =>
+ promise.catch(() => {
+ throw index;
+ })
+ );
+
+ return Promise.race(Promises)
+ .catch(index => {
+ // The promise has rejected, remove it from the list of promises and just continue the race.
+ let promise = promises.splice(index, 1)[0];
+
+ promise.catch(error => BACKGROUND_TERMINAL.log(`Plex request #${ index } failed:`, error));
+ return PromiseRace(promises);
+ });
+}
+
+function $Search_Plex(connection, headers, options) {
+ let type = options.type || 'movie',
+ url = `${ connection.uri }/hubs/search`,
+ field = options.field || 'title';
+
+ if(!options.title)
+ return {};
+
+ if(/movie|film|cinema|theat[re]{2}/i.test(type))
+ type = 'movie';
+ else if(/tv|show|series|episode/i.test(type))
+ type = 'show';
+
+ // Letterboxd can contain special white-space characters. Plex doesn't like this.
+ let title = encodeURIComponent(options.title.replace(/\s+/g, ' ')),
+ finalURL = `${ url }?query=${ field }:${ title }`;
+
+ // BACKGROUND_TERMINAL.warn(`Fetching <${ JSON.stringify(headers) } ${ finalURL } >`);
+ return fetch(finalURL, { headers })
+ .then(response => response.json())
+ .then(data => {
+ let Hub = data.MediaContainer.Hub.find(hub => hub.type === type);
+
+ if(!Hub || !Hub.Metadata) {
+ return { found: false };
+ }
+
+ // We only want to search in Plex libraries with the type "Movie", i.e. not the type "Other Videos".
+ // Weirdly enough Plex doesn't seem to have an easy way to filter those libraries so we invent our own hack.
+ let movies = Hub.Metadata.filter(
+ meta =>
+ meta.Directory ||
+ meta.Genre ||
+ meta.Country ||
+ meta.Role ||
+ meta.Writer
+ ),
+ strip = (string) => string.replace(/\W+/g, '').toLowerCase();
+
+ // This is messed up, but Plex's definition of a year is year when it was available,
+ // not when it was released (which is Movieo's definition).
+ // For examples, see Bone Tomahawk, The Big Short, The Hateful Eight.
+ // So we'll first try to find the movie with the given year, and then + 1 it.
+ // Added [strip] to prevent mix-ups, see: "Kingsman: The Golden Circle" v. "The Circle"
+ let media = movies.find(meta => ((meta.year == +options.year) && strip(meta.title) == strip(options.title))),
+ key = null;
+
+ if(!media) {
+ media = movies.find(meta => ((meta.year == +options.year + 1) && strip(meta.title) == strip(options.title)));
+ }
+
+ key = !!media? media.key.replace('/children', ''): key;
+
+ return {
+ found: !!media,
+ key
+ };
+ })
+ .catch(error => { throw error });
+}
+
+async function Search_Plex(request, sendResponse) {
+ let { options, serverConfig } = request,
+ headers = {
+ 'X-Plex-Token': serverConfig.token,
+ 'Accept': 'application/json'
+ };
+
+ // Try all Plex connection URLs
+ let requests = serverConfig.connections.map(connection =>
+ $Search_Plex(connection, headers, options)
+ );
+
+ try {
+ // See what connection URL finishes the request first and pick that one.
+ // TODO: optimally, as soon as the first request is finished, all other requests would be cancelled using AbortController.
+ let result = await PromiseRace(requests);
+
+ sendResponse(result);
+ } catch (error) {
+ sendResponse({ error: String(error), location: '@0B: Search_Plex' });
+ }
+}
+
+// Chrome is f**king retarted...
+// Instead of having an object returned (for the context-menu)
+// You have to make API calls on ALL clicks...
+chrome.contextMenus.onClicked.addListener(item => {
+ if(!/^W2P/i.test(item.menuItemId)) return;
+
+ let url = "", dnl = false,
+ db = item.menuItemId.slice(-2).toLowerCase(),
+ pv = external.ID_PROVIDER.slice(0, 2).toLowerCase(),
+ qu = external.ITEM_ID,
+ tl = external.SEARCH_TITLE,
+ yr = external.ITEM_YEAR,
+ tt = external.ITEM_TITLE,
+ lt = external.FILE_TITLE,
+ ft = external.FILE_TYPE,
+ fp = external.FILE_PATH,
+ p = (s, r = '+') => s.replace(/-/g, r);
+
+ switch(db) {
+ case 'im':
+ url = (qu && pv == 'im')?
+ `imdb.com/title/${ qu }/`:
+ `imdb.com/find?ref_=nv_sr_fn&s=all&q=${ tt }`;
+ break;
+ case 'tm':
+ url = (qu && pv == 'tm')?
+ `themoviedb.org/${ external.ITEM_TYPE == 'show'? 'tv': 'movie' }/${ qu }`:
+ `themoviedb.org/search?query=${ tt }`;
+ break;
+ case 'tv':
+ url = (qu && pv == 'tv')?
+ `thetvdb.com/series/${ tl }#${ qu }`: // TVDb accepts either: a title, or a series number... but only one
+ `thetvdb.com/search?q=${ p(tl) }`;
+ break;
+ case 'xx':
+ url = external.SEARCH_PROVIDER == 'VO'?
+ `google.com/search?q=${ p(tl) }+site:vumoo.to`:
+ external.SEARCH_PROVIDER == 'GX'?
+ `gostream.site/?s=${ p(tl) }`:
+ `google.com/search?q="${ p(tl, ' ') } ${ yr }"+${ pv }db`;
+ break;
+ case 'dl':
+ dnl = true;
+ url = external.ITEM_URL;
+ break;
+ default: return; break;
+ }
+
+ if(!dnl)
+ window.open(`https://${ url }`, '_blank');
+ else if(dnl)
+ // try/catch won't work here, so use the first download's callback as an error catcher
+ chrome.downloads.download({
+ url: item.href,
+ filename: `${ fp }${ lt } (${ yr }).${ ft }`,
+ saveAs: true
+ }, id => {
+ if(id === undefined || id === null)
+ chrome.downloads.download({
+ url: item.href,
+ saveAs: true
+ });
+ });
+});
+
+chrome.runtime.onMessage.addListener((request = {}, sender, callback) => {
+ BACKGROUND_TERMINAL.log('From: ' + sender);
+
+ let item = (request.options || request),
+ ITEM_TITLE = item.title,
+ ITEM_YEAR = item.year,
+ ITEM_TYPE = item.type,
+ ID_PROVIDER = (i=>{for(let p in i)if(/^TV(Db)?/i.test(p)&&i[p])return'TVDb';else if(/^TM(Db)?/i.test(p)&&i[p])return'TMDb';return'IMDb'})(item),
+ ITEM_URL = (item.href || ''),
+ FILE_TYPE = (item.tail || 'mp4'),
+ FILE_PATH = (item.path || ''),
+ ITEM_ID = ((i, I)=>{for(let p in i)if(RegExp('^'+I,'i').test(p))return i[p]})(item, ID_PROVIDER);
+
+ if(request.type) {
+ try {
+ switch(request.type) {
+ case 'SEARCH_PLEX':
+ Search_Plex(request, callback);
+ break;
+
+ case 'VIEW_COUCHPOTATO':
+ Open_CouchPotato(request, callback);
+ break;
+
+ case 'PUSH_COUCHPOTATO':
+ Push_CouchPotato(request, callback);
+ break;
+
+ case 'PUSH_RADARR':
+ Push_Radarr(request, callback);
+ break;
+
+ case 'PUSH_SONARR':
+ Push_Sonarr(request, callback);
+ break;
+
+ case 'PUSH_MEDUSA':
+ Push_Medusa(request, callback);
+ break;
+
+ case 'PUSH_WATCHER':
+ Push_Watcher(request, callback);
+ break;
+
+ case 'PUSH_OMBI':
+ Push_Ombi(request, callback);
+ break;
+
+ case 'PUSH_SICKBEARD':
+ Push_SickBeard(request, callback);
+ break;
+
+ case 'OPEN_OPTIONS':
+ chrome.runtime.openOptionsPage();
+ break;
+
+ case 'SEARCH_FOR':
+ if(ITEM_TITLE && ITEM_TYPE)
+ ChangeStatus({ ITEM_ID, ITEM_TITLE, ITEM_TYPE, ID_PROVIDER, ITEM_YEAR, ITEM_URL, FILE_TYPE, FILE_PATH });
+ break;
+
+ case 'SAVE_AS':
+ chrome.contextMenus.update('W2P-DL', {
+ title: `Save as "${ ITEM_TITLE } (${ ITEM_YEAR })" (${ FILE_TYPE })`
+ });
+ break;
+
+ case 'DOWNLOAD_FILE':
+ let FILE_TITLE = ITEM_TITLE.replace(/\-/g, ' ').replace(/[\s\:]{2,}/g, ' - ').replace(/[^\w\s\-\']+/g, '');
+
+ // no try/catch, use callback for that
+ chrome.downloads.download({
+ url: item.href,
+ filename: `${ FILE_TITLE } (${ ITEM_YEAR }).${ FILE_TYPE }`,
+ saveAs: true
+ }, id => {
+ // Error Occured
+ if(id === undefined || id === null)
+ chrome.downloads.download({
+ url: item.href,
+ filename: `${ FILE_TITLE } (${ ITEM_YEAR })`,
+ saveAs: true
+ });
+ });
+ break;
+
+ case 'UPDATE_CONFIGURATION':
+ UpdateConfiguration(true);
+ break;
+
+ case 'PLUGIN':
+ case 'SCRIPT':
+ case '_INIT_':
+ case '$INIT$':
+ case 'FOUND':
+ case 'GRANT_PERMISSION':
+ /* These are meant to be handled by plugn.js */
+ return false;
+
+ default:
+ BACKGROUND_TERMINAL.warn(`Unknown background event [${ request.type }]`);
+ return false;
+ }
+
+ return true;
+ } catch (error) {
+ BACKGROUND_TERMINAL.error(error);
+ callback(String(error));
+ }
+ } else {
+ return false;
+ }
+});
+
+// If background.js is already running, ignore the new state
+// otherwise, use the following to start up
+if(SessionState === false) {
+ SessionState = true;
+
+ __context_parent__ = chrome.contextMenus.create({
+ id: 'W2P',
+ title: 'Web to Plex'
+ });
+
+ __context_save_element__ = chrome.contextMenus.create({
+ id: 'W2P-DL',
+ title: 'Nothing to Save'
+ });
+
+ // Standard search engines
+ for(let array = 'IM TM TV'.split(' '), DL = {}, length = array.length, index = 0, item; index < length; index++)
+ chrome.contextMenus.create({
+ id: 'W2P-' + (item = array[index]),
+ parentId: __context_parent__,
+ title: `Using ${ item }Db`,
+ type: 'checkbox',
+ checked: true // implement a way to use the checkboxes?
+ });
+
+ // Non-standard search engines
+ chrome.contextMenus.create({
+ id: 'W2P-XX',
+ parentId: __context_parent__,
+ title: `Using best guess`,
+ type: 'checkbox',
+ checked: true // implement a way to use the checkboxes?
+ });
+
+}
+
+// turn object into URL paramaeters:
+// { data: data, ... } => data=data&...
+function formify(data) {
+ let body = [];
+ for(let key in data)
+ body.push(`${ key }=${ data[key] }`);
+ return body.join('&');
+}
+
+if(chrome.runtime.lastError)
+ /* Attempt Error Suppression */;
diff --git a/win/cloud/__layout__.js b/win/cloud/__layout__.js
new file mode 100644
index 0000000..fac22ba
--- /dev/null
+++ b/win/cloud/__layout__.js
@@ -0,0 +1,39 @@
+// optional
+// "Friendly Name" requires: api|username|password|token
+// api - the user's api tokens (external, such as TMDb/OMDb)
+// username - the user's usernames (internal, such as Radarr/Sonarr/etc.)
+// password - the user's passwords (internal)
+// token - the user's tokens (internal)
+// Example: "Web to Plex" requires: api, token
+
+let script = {
+ // required
+ "url": "< URL RegExp >",
+ // Example: *://*.amazon.*/*/video/(detail|buy)/*
+ // *:// - match any protocol (http, https, etc.)
+ // *.amazon - match any sub-domain (www, ww5, etc.)
+ // .* - match any TLD (com, net, org, etc.)
+ // /* - match any path
+ // (detail|buy) - match one of the items
+
+ // optional
+ "ready": () => { /* return a boolean to describe if the page is ready */ },
+
+ // optional
+ "timeout": 1000, // if the script fails to complete, retry after ... milliseconds
+
+ // required
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let title = $('#title').first,
+ year = $('#year').first,
+ image = $('#image').first,
+ type = script.getType(); // described below
+
+ return { type, title, year, image };
+ },
+
+ // optional | functionality only
+ "getType": () => 'movie' || 'show',
+};
diff --git a/win/cloud/__test__.js b/win/cloud/__test__.js
new file mode 100644
index 0000000..3683c78
--- /dev/null
+++ b/win/cloud/__test__.js
@@ -0,0 +1,37 @@
+let script = {
+ // required
+ "url": "*://ephellon.github.io/web.to.plex/test/*",
+ // Example: *://*.amazon.com/*/video/(detail|buy)/*
+ // *:// - match any protocol (http, https, etc.)
+ // *.amazon.com - match any sub-domain (www, ww5, etc.)
+ // /* - match any path
+ // (detail|buy) - match one of the items
+
+ // optional
+ "ready": () => {
+ /* return a boolean to describe if the page is ready */
+ return !!$('#title').first.textContent.length;
+ },
+
+ // optional
+ "timeout": 1000, // if the script fails to complete, retry after ... milliseconds
+
+ // required
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let title = $('#title').first,
+ year = $('#year').first,
+ image = $('#poster').first,
+ type = script.getType(); // described below
+
+ title = title.textContent;
+ year = +year.textContent;
+ image = image.src || '';
+
+ return { type, title, year, image };
+ },
+
+ // optional | functionality only
+ "getType": () => $('#example').first.getAttribute('type'),
+};
diff --git a/win/cloud/allocine.js b/win/cloud/allocine.js
new file mode 100644
index 0000000..f0dca7b
--- /dev/null
+++ b/win/cloud/allocine.js
@@ -0,0 +1,29 @@
+let script = {
+ "url": "*://*.allocine.fr/(film|series)/*",
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let title = $('.titlebar-title').first,
+ year = $('.date, .meta-body font').first,
+ image = $('.thumbnail-img').first,
+ type = script.getType();
+
+ if(!title || !year)
+ return 1000;
+
+ title = title.textContent.trim();
+ image = image.src;
+
+ year.textContent.replace(/(\d{4})/, '');
+ year = +R.$1;
+
+ return { type, title, year, image };
+ },
+
+ "getType": () => {
+ let { pathname } = top.location;
+
+ return /\/(film)\//.test(pathname)? 'film': 'show';
+ },
+};
diff --git a/win/cloud/amazon.js b/win/cloud/amazon.js
new file mode 100644
index 0000000..0779401
--- /dev/null
+++ b/win/cloud/amazon.js
@@ -0,0 +1,55 @@
+// Web to Plex - Toloka Plugin
+// Aurthor(s) - @ephellon (2019)
+
+/* Minimal Required Layout *
+ script {
+ url: string,
+ init: function => ({ type:string, title:string, year:number|null|undefined })
+ }
+*/
+
+// REQUIRED [script:object]: The script object
+let script = {
+ // REQUIRED [script.url]: this is what you ask Web to Plex access to; currently limited to a single domain
+ "url": "*://*.amazon.com/*/video/detail/*",
+
+ // PREFERRED [script.ready]: a function to determine that the page is indeed ready
+ "ready": () => !$('[data-automation-id="imdb-rating-badge"], #most-recent-reviews-content > *:first-child').empty,
+
+ // REQUIRED [script.init]: it will always be fired after the page and Web to Plex have been loaded
+ // OPTIONAL [ready]: if using script.ready, Web to Plex will pass a boolean of the ready state
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let title = $('[data-automation-id="title"], #aiv-content-title, .dv-node-dp-title')
+ .first.textContent
+ .replace(/(?:\(.+?\)|(\d+)|\d+\s+seasons?\s+(\d+))\s*$/gi, '')
+ .trim(),
+ // REQUIRED [title:string]
+ // you have access to the exposed "helper.js" file within the extension
+ year = +(
+ !(_year = $('[data-automation-id="release-year-badge"], .release-year')).empty?
+ _year.first.textContent.trim():
+ (R.$1 || R.$2 || YEAR)
+ ),
+ // PREFERRED [year:number, null, undefined]
+ image = (
+ (_image = $('.av-bgimg__div, div[style*="sgp-catalog-images"]')).empty?
+ $('.av-fallback-packshot img').src:
+ getComputedStyle(_image.first).backgroundImage.replace(/[^]*url\((["']?)(.+?)\1\)[^]*/i, '$2')
+ ),
+ // the rest of the code is up to you, but should be limited to a layout similar to this
+ type = script.getType();
+
+ // REQUIRED [{ type:'movie', 'show'; title:string; year:number }]
+ // PREFERRED [{ image:string; IMDbID:string; TMDbID:string, number; TVDbID:string, number }]
+ return { type, title, year, image };
+ },
+
+ // OPTIONAL: the rest of this code is purely for functionality
+ "getType": () => {
+ return !$('[data-automation-id*="season"], [class*="season"], [class*="episode"], [class*="series"]').empty?
+ 'tv':
+ 'movie'
+ },
+};
diff --git a/win/cloud/couchpotato.js b/win/cloud/couchpotato.js
new file mode 100644
index 0000000..f8f6ddc
--- /dev/null
+++ b/win/cloud/couchpotato.js
@@ -0,0 +1,40 @@
+let script = {
+ "url": "*://*.couchpotato.life/(movies|shows)/*",
+
+ "ready": () => !$('.media-body .clearfix').empty && $('.media-body .clearfix').first.children.length,
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let title = $('[itemprop="description"]').first,
+ year = title.previousElementSibling,
+ image = $('img[src*="wp-content"]'),
+ type = script.getType(),
+ IMDbID = script.getIMDbID();
+
+ title = title.textContent.trim();
+ year = +year.textContent.trim();
+ image = image.empty? '': image.first.src;
+
+ return { type, title, year, image, IMDbID };
+ },
+
+ "getType": () => {
+ let pathname = window.location.pathname;
+
+ return /^\/movies?\//.test(pathname)?
+ 'movie':
+ /^\/shows?\//.test(pathname)?
+ 'show':
+ null
+ },
+
+ "getIMDbID": () => {
+ let link = $('[href*="imdb.com/title/tt"]');
+
+ if(!link.empty)
+ return link.first.href
+ .replace(/^.*imdb\.com\/title\//, '')
+ .replace(/\/(?:maindetails\/?)?$/, '');
+ },
+};
diff --git a/win/cloud/fandango.js b/win/cloud/fandango.js
new file mode 100644
index 0000000..04a1a4e
--- /dev/null
+++ b/win/cloud/fandango.js
@@ -0,0 +1,20 @@
+let script = {
+ "url": "*://*.fandango.com/[\\w\\-]+/movie-overview",
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let title = $('.subnav__title').first,
+ year = $('.movie-details__release-date').first,
+ image = $('.movie-details__movie-img').first,
+ type = 'movie';
+
+ title = title.textContent.trim().split(/\n+/)[0].trim();
+ year = +year.textContent.replace(/.*(\d{4}).*/, '$1').trim();
+ image = image.empty? '': image.src;
+
+ title = title.replace(RegExp(`\\s*\\((${ year })\\)`), '');
+
+ return { type, title, year, image };
+ },
+};
diff --git a/win/cloud/flickmetrix.js b/win/cloud/flickmetrix.js
new file mode 100644
index 0000000..c0843b6
--- /dev/null
+++ b/win/cloud/flickmetrix.js
@@ -0,0 +1,61 @@
+let script = {
+ "url": "*://*.flickmetrix.com/(watchlist|seen|favourites|trash|share|\\?)?",
+
+ "ready": () => $('#loadingOverlay > *').empty || getComputedStyle($('#loadingOverlay').first).display === 'none',
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ if(script.isList())
+ return script.processList(ready);
+
+ let element = $('#singleFilm'), type = 'movie';
+
+ _title = $('.title').first;
+ _year = $('.title + *').first;
+ _image = $('img').first;
+
+ let title = _title.textContent.trim(),
+ year = +_year.textContent.replace(/^\(|\)$/g, '').trim(),
+ image = _image.src,
+ IMDbID = script.getIMDbID(element);
+
+ return { type, title, year, image, IMDbID };
+ },
+
+ "getIMDbID": (element) => {
+ let link = $('[href*="imdb.com/title/tt"]').first;
+
+ if(link)
+ return link.href.replace(/^.*imdb\.com\/title\//, '').replace(/\/(?:maindetails\/?)?$/, '');
+ },
+
+ "isList": () => $('#singleFilm').empty && !/\bid=\d+\b/i.test(location.search),
+
+ "processList": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let films = [], list = $('.film'), length = list.length - 1, type = 'movie';
+
+ list.forEach((element, index, array) => {
+ _title = $('.title', element).first;
+ _year = $('.title + *', element).first;
+ _image = $('img', element).first;
+
+ if(!_title)
+ return;
+
+ let title = _title.textContent.trim(),
+ year = +_year.textContent.replace(/^\(|\)$/g, '').trim(),
+ image = _image.src,
+ IMDbID = script.getIMDbID(element);
+
+ films.push({ type, title, year, image, IMDbID });
+ });
+
+ if(!films.length)
+ return new Notification('error', 'Failed to process list');
+
+ return films;
+ },
+};
diff --git a/win/cloud/google.js b/win/cloud/google.js
new file mode 100644
index 0000000..336e7ee
--- /dev/null
+++ b/win/cloud/google.js
@@ -0,0 +1,60 @@
+let SHOW = '[href*="thetvdb.com/"][href*="id="], [href*="thetvdb.com/series/"], [href*="themoviedb.org/tv/"], [href*="imdb.com/title/tt"][href$="externalsites"]',
+ FILM = '[href*="themoviedb.org/tv/"], [href*="imdb.com/title/tt"]';
+ // FILM = '#media_result_group, ...'
+
+let script = {
+ "url": "*://www.google.com/search",
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let type = script.getType();
+
+ if(type == 'movie') {
+ let _type = $('[href*="imdb.com/title/tt"]:not([class])').first; // in case a tv show is incorrectly identified
+
+ if(_type) {
+ type = _type.textContent;
+
+ type = /\b(tv|show|series)\b/i.test(type)? 'show': /* /\b(movie|film|cinema|(?:\d+h\s+)?\d+m)\b/i.test(type)? 'movie': 'error' */ 'movie';
+ }
+
+ _title = $('#wp-tabs-container [data-attrid="title"i] span, [data-local-attribute], [role="heading"i] > div > a').first;
+ _year = $('#wp-tabs-container [data-attrid="subtitle"i] span, [role="heading"i] > div:last-child').first;
+ _image = $('#media_result_group img, [data-attrid="image"i] img').first;
+ } else if(type == 'show') {
+ _title = $(SHOW).first.querySelector('*');
+ _year = { textContent: '' };
+ _image = { src: '' };
+ } else if(type === 'error') {
+ return -1;
+ }
+
+ (_year.textContent + '').replace(/(\d{4})/, '');
+
+ let year = +R.$1,
+ title = _title.textContent.replace((type == 'movie'? /^(.+)$/: /(.+)(?:(?:\:\s*series\s+info|\-\s*(?:all\s+episodes|season)).+)$/i), '$1').trim(),
+ image = (_image || {}).src;
+
+ year = year > 999? year: 0;
+
+ let IMDbID = script.getIMDbID();
+
+ return { type, title, year, image, IMDbID };
+ },
+
+ "getIMDbID": () => {
+ let link = $('[href*="imdb.com/title/tt"]:not([class])').first;
+
+ if(link)
+ return link.href.replace(/.*(tt\d+).*/, '$1');
+ },
+
+ "getType": () => (
+ !$(SHOW).empty?
+ 'show':
+ !$(FILM).empty?
+ 'movie':
+ 'error'
+ ),
+};
diff --git a/win/cloud/google.play.js b/win/cloud/google.play.js
new file mode 100644
index 0000000..603e989
--- /dev/null
+++ b/win/cloud/google.play.js
@@ -0,0 +1,24 @@
+let script = {
+ "url": "*://play.google.com/store/(movies|tv)/details/*",
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let type = script.getType(),
+ title = $('h1').first,
+ year = $(`h1 ~ div span:${ type == 'movie'? 'first': 'last' }-of-type`).first,
+ image = $('img[alt="cover art" i]').first;
+
+ title = title.textContent.replace(/\s*\(\s*(\d{4})\s*\).*?$/, '').trim();
+ year = +(year.textContent || R.$1).replace(/^.*?(\d{4})/, '$1').trim();
+ image = (image || {}).src;
+
+ return { type, title, year, image };
+ },
+
+ "getType": () => (
+ location.pathname.startsWith('/store/movies')?
+ 'movie':
+ 'show'
+ ),
+};
diff --git a/win/cloud/gostream.js b/win/cloud/gostream.js
new file mode 100644
index 0000000..4846ff0
--- /dev/null
+++ b/win/cloud/gostream.js
@@ -0,0 +1,22 @@
+let script = {
+ "url": "*://*.gostream.site/(?!genre|most-viewed|top-imdb|contact)",
+
+ "ready": () => { let e = $('.movieplay iframe, .desc iframe'); return e.empty? false: e.first.src != '' },
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let title = $('h3[itemprop="name"]').first,
+ year = $('.mvic-desc [href*="year/"]').first,
+ image = $('.hiddenz, [itemprop="image"]').first,
+ type = 'movie';
+
+ Notify('update', 'Select the OL/VH server');
+
+ title = title.textContent.trim();
+ year = +(year? year.textContent.trim(): 0);
+ image = (image? image.src: null);
+
+ return { type, title, year, image };
+ },
+};
diff --git a/win/cloud/hulu.js b/win/cloud/hulu.js
new file mode 100644
index 0000000..89a8081
--- /dev/null
+++ b/win/cloud/hulu.js
@@ -0,0 +1,48 @@
+let script = {
+ "url": "*://*.hulu.com/(watch|series|movie)/*",
+
+ "ready": () => !$('[class$="__meta"]').empty,
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+ let { pathname } = top.location;
+ let type, title, year, image;
+
+ if(/^\/(series|movie)\//.test(pathname)) {
+ type = R.$1;
+ title = $('[class~="masthead__title"i]').first;
+ year = $('[class~="masthead__meta"i]').child(type == 'series'? 4: 3);
+ image = $('[class~="masthead__artwork"i]').first;
+
+ title = title.textContent;
+ year = +year.textContent;
+ type = /\b(tv|show|season|series)\b/i.test(type)? 'show': 'movie';
+ image = image? image.src: null;
+ } else {
+ title = $('[class$="__second-line"]').first;
+ year = (new Date).getFullYear();
+ type = script.getType();
+
+ title = title.textContent;
+ }
+
+ if(!title)
+ return 5000;
+
+ return { type, title, year, image };
+ },
+
+ "getType": () => {
+ let { pathname } = top.location;
+
+ if(/^\/series\//.test(pathname)) {
+ return 'show';
+ } else {
+ let tl = $('[class$="__third-line"]').first;
+
+ return /^\s*$/.test(tl.textContent)?
+ 'movie':
+ 'show';
+ }
+ },
+};
diff --git a/win/cloud/imdb.js b/win/cloud/imdb.js
new file mode 100644
index 0000000..dc60999
--- /dev/null
+++ b/win/cloud/imdb.js
@@ -0,0 +1,119 @@
+let script = {
+ "url": "*://*.imdb.com/(title|list)/(tt|ls)\\d+/(#*|?*)?$",
+
+ "ready": () => !$('#servertime').empty,
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let type = script.getType(),
+ IMDbID = script.getIMDbID(),
+ title, year, image;
+
+ let usa = /\b(USA?|United\s+States)\b/i,
+ date, country, reldate, regdate, alttitle, options;
+
+ switch(type) {
+ case 'movie':
+ title = $('.originalTitle, .title_wrapper h1');
+ alttitle = title.first;
+ reldate = $('.title_wrapper [href*="/releaseinfo"]').first;
+ year = $('.title_wrapper #titleYear').first;
+ image = $('img[alt$="poster"i]').first;
+
+ // TODO: Less risky way to accompilsh this?
+ title = title.last.childNodes[0].textContent.trim();
+ alttitle = (alttitle == title? title: alttitle.childNodes[0].textContent.trim());
+ title = usa.test(country)? title: alttitle;
+ country = reldate.textContent.replace(/[^]+\((\w+)\)[^]*?$/, '$1');
+ year = +script.clean(year.textContent);
+ image = (image || {}).src;
+ options = { type, title, alttitle, year, image, IMDbID };
+ break;
+
+ case 'show':
+ title = $('.originalTitle, .title_wrapper h1');
+ alttitle = title.first;
+ reldate = $('.title_wrapper [href*="/releaseinfo"]').first;
+ date = $('title').first.textContent.trim();
+ regdate = date.match(/Series\s*\(?(\d{4})(?:[^\)]+\))?/i);
+ image = $('img[alt$="poster"i]').first;
+
+ // TODO: Less risky way to accompilsh this?
+ title = title.last.textContent.trim();
+ alttitle = (alttitle == title? title: alttitle.childNodes[0].textContent.trim());
+ title = usa.test(country)? title: alttitle;
+ country = reldate.textContent.replace(/[^]+\((\w+)\)[^]*?$/, '$1');
+ year = parseInt(regdate[1]);
+ image = (image || {}).src;
+ options = { type, title, alttitle, year, image, IMDbID };
+ break;
+
+ case 'list':
+ let items = $('#main .lister-item');
+
+ options = [];
+
+ if(!/[\?\&]mode=simple\b/i.test(top.location.search))
+ top.open(location.href.replace(/([\?\&]|\/$)(?:mode=\w+&*)?/, '$1mode=simple&'), '_self');
+
+ items.forEach(element => {
+ let option = script.process(element);
+
+ if(option)
+ options.push(option);
+ });
+ break;
+
+ default: return null;
+ }
+
+ return options;
+ },
+
+ "getType": () => {
+ let tag = $('meta[property="og:type"]').first,
+ type = 'error';
+
+ if(tag) {
+ switch(tag.content) {
+ case 'video.movie':
+ type = 'movie';
+ break;
+
+ case 'video.tv_show':
+ type = 'show';
+ break;
+ };
+ } else if(top.location.pathname.startsWith('/list/')) {
+ type = 'list';
+ }
+
+ return type;
+ },
+
+ "getIMDbID": () => {
+ let tag = $('meta[property="pageId"]');
+
+ return tag? tag.content: null;
+ },
+
+ "process": (element) => {
+ let title = $('.col-title a', element).first,
+ year = $('.col-title a + *', element).first,
+ image = $('img.loadlate, img[data-tconst]', element).first,
+ IMDbID = title.href.replace(/^[^]*\/(tt\d+)\b[^]*$/, '$1'),
+ type;
+
+ title = title.textContent.trim();
+ year = script.clean(year.textContent);
+ image = image.src;
+ type = (/[\-\u2010-\u2015]/.test(year)? 'show': 'movie');
+
+ year = +year;
+
+ return { type, title, year, image, IMDbID };
+ },
+
+ "clean": year => (year + '').replace(/^\(|\)$/g, '').trim(),
+};
diff --git a/win/cloud/itunes.js b/win/cloud/itunes.js
new file mode 100644
index 0000000..c764b89
--- /dev/null
+++ b/win/cloud/itunes.js
@@ -0,0 +1,48 @@
+let script = {
+ "url": "",
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let title, year, image, type = script.getType();
+
+ switch(type) {
+ case 'movie':
+ title = $('[class*="movie-header__title"i]').first.textContent;
+ year = +$('[datetime]').first.textContent;
+ image = ($('[class*="product"] ~ * picture img').first || {}).src;
+
+ title = title.replace(RegExp(`\\s*\\(${ year }|\\d{4}\\)`), '');
+ year = year || +R.$1;
+ break;
+
+ case 'tv':
+ title = $('h1[itemprop="name"], h1').first.textContent.replace(/\s*\((\d+)\)\s*/, '').trim();
+ year = +$('.release-date > *:last-child').first.textContent.replace(/[^]*(\d{4})[^]*?$/g, '$1').trim();
+ image = $('[class*="product"] ~ * picture img').first.src;
+
+ title = title.replace(RegExp(`\\s*\\(${ year }\\)`), '');
+ break;
+
+ default:
+ /* Error */
+ return {};
+ }
+
+ setTimeout(script.adjustButton, 1000);
+
+ return { type, title, year, image };
+ },
+
+ "getType": () => {
+ return /(\/\w+)?\/tv-season\//.test(top.location.pathname)?
+ 'tv':
+ 'movie'
+ },
+
+ "adjustButton": () => {
+ let button = $('.web-to-plex-button').first;
+
+ button.attributes.style.value += '; box-sizing: border-box !important; font-size: 16px !important; line-height: normal !important;';
+ },
+};
diff --git a/win/cloud/justwatch.js b/win/cloud/justwatch.js
new file mode 100644
index 0000000..35cedeb
--- /dev/null
+++ b/win/cloud/justwatch.js
@@ -0,0 +1,31 @@
+let script = {
+ "url": "*://*.justwatch.com/(\\w{2})/(tv(?:-show)|movie)/*",
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let title = $('.title-block').first,
+ year = $('.title-block .text-muted').first,
+ image = $('.title-poster__image').first,
+ type = script.getType();
+
+ if(!title || !year)
+ return 1000;
+
+ year = year.textContent;
+ title = title.firstElementChild.firstChild.textContent.trim();
+ year = +year.replace(/\D+/g, '');
+ image = image.src;
+
+ return { type, title, year, image };
+ },
+
+ "getType": () => {
+ let { pathname } = top.location;
+
+ if(/^\/tv(-show)?\//.test(pathname))
+ return 'show';
+ else
+ return 'movie';
+ },
+};
diff --git a/win/cloud/letterboxd.js b/win/cloud/letterboxd.js
new file mode 100644
index 0000000..d8c1ac7
--- /dev/null
+++ b/win/cloud/letterboxd.js
@@ -0,0 +1,76 @@
+let script = {
+ "url": "*://*.letterboxd.com/(film|list)/",
+
+ "ready": () => (script.getType('list')? true: !$('.js-watch-panel').empty),
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let title, year, image, type = script.getType(), IMDbID;
+
+ switch(type) {
+ case 'movie':
+ title = $('.headline-1[itemprop="name"]').first.textContent.trim();
+ year = +$('small[itemprop="datePublished"]').first.textContent.trim();
+ image = ($('.image').first || {}).src;
+ IMDbID = script.getIMDbID(type);
+
+ return { type, title, year, image, IMDbID };
+ break;
+
+ case 'list':
+ let items = $('.poster-list .poster-container'),
+ options = [];
+
+ items.forEach((element, index, array) => {
+ let option = script.process(element);
+
+ if(option)
+ options.push(option);
+ });
+
+ return options;
+ break;
+
+ default:
+ /* Error */
+ return {};
+ }
+ },
+
+ "getType": (suspectedType) => {
+ let type = /^\/(film)\//i.test(top.location.pathname)? 'movie': 'list';
+
+ if(suspectedType)
+ return type == suspectedType;
+
+ return type;
+ },
+
+ "getIMDbID": (type) => {
+ if(type == 'movie') {
+ let link = $(
+ '.track-event[href*="imdb.com/title/tt"i]'
+ );
+
+ if(!link.empty) {
+ link = link.first.href.replace(/^.*imdb\.com\/title\//i, '');
+
+ return link.replace(/\/(?:maindetails\/?)?$/, '');
+ }
+ }
+ },
+
+ "process": (element) => {
+ let title = $('.frame-title', element).first,
+ image = $('img', element).first,
+ type = 'movie',
+ year;
+
+ title = title.textContent.replace(/\((\d+)\)/, '').trim();
+ year = +RegExp.$1;
+ image = image.src;
+
+ return { type, title, year, image };
+ },
+};
diff --git a/win/cloud/metacritic.js b/win/cloud/metacritic.js
new file mode 100644
index 0000000..d61d41d
--- /dev/null
+++ b/win/cloud/metacritic.js
@@ -0,0 +1,49 @@
+let script = {
+ "url": "*://*.metacritic.com/(movie|tv|list)/*",
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let title, year, image,
+ type = script.getType();
+
+ switch(type) {
+ case 'tv':
+ case 'movie':
+ title = $('.product_page_title > *, .product_title').first;
+ year = $('.product_page_title > .release_year, .product_data .release_data').first;
+ image = $('.summary_img').first;
+
+ title = title.textContent.replace(/\s+/g, ' ').trim();
+ year = +year.textContent.replace(/\s+/g, ' ').replace(/.*(\d{4}).*$/, '$1').trim();
+ image = (image || {}).src;
+
+ type = type == 'tv'? 'show': type;
+
+ return { type, title, year, image };
+ break;
+
+ case 'list':
+ /* Not yet implemented */
+ break;
+
+ default:
+ /* Error */
+ return {};
+ break;
+ }
+ },
+
+ "getType": () => {
+ /^\/(movie|tv|list)\//.test(top.location.pathname);
+
+ let type = RegExp.$1;
+
+ return type;
+ },
+
+ "process": (element) => {
+ /* Not implemented... Metacritic has too much sh*t loading to even try to open a console */
+ /* Targeted for v5/v6 */
+ },
+};
diff --git a/win/cloud/moviemeter.js b/win/cloud/moviemeter.js
new file mode 100644
index 0000000..84d0080
--- /dev/null
+++ b/win/cloud/moviemeter.js
@@ -0,0 +1,34 @@
+let script = {
+ "url": "*://*.moviemeter.nl/film/\\d+",
+
+ "ready": () => !$('.rating + p font').empty,
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let title = $('.details span').first,
+ year = $('.details *').first,
+ image = $('.poster').first,
+ type = script.getType();
+
+ if(!title || !year)
+ return 1000;
+
+ year = year.lastChild.textContent;
+ title = title.textContent.replace(year, '').trim();
+ year = +year.replace(/\D+/g, '');
+ image = image.src;
+
+ return { type, title, year, image };
+ },
+
+ "getType": () => {
+ let time = $('.rating + p font').last;
+
+ time = time.textContent;
+
+ if(/(series|show)/.test(time))
+ return 'show';
+ return 'film';
+ },
+};
diff --git a/win/cloud/movieo.js b/win/cloud/movieo.js
new file mode 100644
index 0000000..bca58ba
--- /dev/null
+++ b/win/cloud/movieo.js
@@ -0,0 +1,75 @@
+let script = {
+ "url": "*://*.movieo.me/*",
+
+ "ready": () => !$('.share-box, .zopim').empty,
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let title, year, image, IMDbID,
+ type = script.getType();
+
+ switch(type) {
+ case 'movie':
+ title = $('#doc_title').first;
+ year = $('meta[itemprop="datePublished"i]').first;
+ image = $('img.poster').first;
+
+ title = title.dataset.title.trim();
+ year = +year.content.slice(0, 4);
+ image = (image || {}).src;
+ IMDbID = script.getIMDbID();
+ break;
+
+ case 'list':
+ let items = $('[data-title][data-id]'),
+ options = [];
+
+ items.forEach((element, index, array) => {
+ let option = script.process(element);
+
+ if(option)
+ options.push(option);
+ });
+
+ return options;
+ break;
+
+ default:
+ /* Error */
+ return {};
+ break;
+ }
+
+ return { type, title, year, image, IMDbID };
+ },
+
+ "getType": () => {
+ let type = /\/(black|seen|watch)?lists?\//i.test(top.location.pathname)?
+ 'list':
+ 'movie';
+
+ return type;
+ },
+
+ "getIMDbID": () => {
+ let link = $(
+ '.tt-parent[href*="imdb.com/title/tt"i]'
+ ).first;
+
+ if(link)
+ return link.href.replace(/^[^]*\/title\//i, '');
+ },
+
+ "process": (element) => {
+ let title = $('.title', element).first,
+ image = $('.poster-cont', element).first,
+ year, type = 'movie';
+
+ title = title.textContent.trim().replace(/\s*\((\d{4})\)/, '');
+ year = +RegExp.$1;
+ image = image.dataset.src;
+
+ return { type, title, year, image };
+ },
+};
diff --git a/win/cloud/netflix.js b/win/cloud/netflix.js
new file mode 100644
index 0000000..1a22407
--- /dev/null
+++ b/win/cloud/netflix.js
@@ -0,0 +1,28 @@
+let script = {
+ "url": "*://*.netflix.com/watch/\\d+",
+
+ "ready": () => {
+ let element = $('[class$="__time"]').first;
+
+ return element && !/^([0:]+|null|undefined)?$/.test(element.textContent);
+ },
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let title = $('.video-title h4').first,
+ year = 0,
+ image = '',
+ type = script.getType();
+
+ title = title.textContent;
+
+ return { type, title, year, image };
+ },
+
+ "getType": () => {
+ let element = $('[class*="playerEpisodes"]').first;
+
+ return !!element? 'show': 'movie';
+ },
+};
diff --git a/win/cloud/plex.js b/win/cloud/plex.js
new file mode 100644
index 0000000..de3de74
--- /dev/null
+++ b/win/cloud/plex.js
@@ -0,0 +1,36 @@
+let script = {
+ "url": "*://app.plex.tv/desktop#!/(server/(?:[a-f\\d]+)|provider/(?:tv.plex.provider.vod))/(details|list)\\?*",
+
+ "ready": () => $('.loading').empty,
+
+ "timeout": 5000,
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let title = $('[data-qa-id$="maintitle"i] *').first,
+ year = $('[data-qa-id$="secondtitle"i] *').first,
+ type = script.getType();
+
+ if(!title || !year || type == 'error')
+ return 5000;
+
+ title = title.textContent;
+ year = year.textContent;
+
+ year = +(year || YEAR);
+
+ return { type, title, year };
+ },
+
+ "getType": () => {
+ let cell = $('[data-qa-id$="celltitle"i]').first;
+
+ if(!cell)
+ return 'error';
+
+ if(/seasons?/i.test(cell.textContent))
+ return 'show';
+ return 'movie';
+ },
+};
diff --git a/win/cloud/plugin/foxsearchlight.js b/win/cloud/plugin/foxsearchlight.js
new file mode 100644
index 0000000..6d9ef04
--- /dev/null
+++ b/win/cloud/plugin/foxsearchlight.js
@@ -0,0 +1,26 @@
+let plugin = {
+ "url": "*://*.foxsearchlight.com/(?!films|search|$)",
+
+ "ready": () => (getComputedStyle($('.pace').first).opacity == '0'),
+
+ "timeout": 5000,
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let title = $('.lockout h1').first,
+ year = $('.lockout h3').first,
+ image = $('.poster img').first;
+
+ if(!title)
+ return -1;
+
+ title = title.textContent.trim();
+ image = image.src;
+
+ year.textContent.replace(/(\d{4})\s*$/, '$1');
+ year = +R.$1 || YEAR;
+
+ return { type: 'film', title, year, image };
+ },
+};
diff --git a/win/cloud/plugin/freemoviescinema.js b/win/cloud/plugin/freemoviescinema.js
new file mode 100644
index 0000000..a6727c6
--- /dev/null
+++ b/win/cloud/plugin/freemoviescinema.js
@@ -0,0 +1,23 @@
+let plugin = {
+ "url": "*://*.freemoviescinema.com/watch/*",
+
+ "ready": () => !$('.row .row h2 a').empty,
+
+ "timeout": 1000,
+
+ "init": (ready) => {
+ let R = RegExp;
+
+ let title, year, image,
+ type = 'movie';
+
+ title = $('.row .row h2 a').first;
+ image = $('[class*="hero"i]').first;
+
+ title = title.textContent.replace(/\s*\((\d{4})\)/, '');
+ year = +R.$1;
+ image = image.getAttribute('style').replace(/url\((["']?)([^]+?)\1\)/, '$1');
+
+ return { type, title, year, image };
+ },
+};
diff --git a/win/cloud/plugin/go.js b/win/cloud/plugin/go.js
new file mode 100644
index 0000000..bc7fbd0
--- /dev/null
+++ b/win/cloud/plugin/go.js
@@ -0,0 +1,34 @@
+let plugin = {
+ "url": "*://freeform.go.com/(movies|shows)/*",
+
+ "ready": () => !$('.container h1').empty,
+
+ "timeout": 1000,
+
+ "init": (ready) => {
+ let R = RegExp;
+
+ let title, year, image,
+ type = plugin.getType();
+
+ if(type == 'movie') {
+ title = $('.container h1').first;
+ year = $('.panel-meta-data').first;
+
+ title = title.textContent;
+ year = +(year.textContent.split(/\s*-\s*/).filter(y => /^\d+$/.test(y))[0])
+ } else if(type == 'show') {
+ title = $('img.hero').first;
+
+ title = title.getAttribute('alt');
+ }
+
+ return { type, title, year, image };
+ },
+
+ "getType": () => {
+ return /\bmovies\b/.test(location.pathname)?
+ 'movie':
+ 'show';
+ },
+};
diff --git a/win/cloud/plugin/indomovietv.js b/win/cloud/plugin/indomovietv.js
new file mode 100644
index 0000000..ec1f629
--- /dev/null
+++ b/win/cloud/plugin/indomovietv.js
@@ -0,0 +1,59 @@
+let plugin = {
+ "url": "*://*.indomovietv.*/(?!tag|$)",
+ // TLD changes often: net, org
+
+ "ready": () => !$('[itemprop="name"i]:not(meta), [itemprop="datePublished"i]').empty,
+
+ "timeout": 1000,
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let title = $('[itemprop="name"i]:not(meta)').first,
+ year = $('[itemprop="datePublished"i]').first,
+ image = $('[itemprop="image"i]').first,
+ type = 'movie';
+
+ title = title.textContent;
+ year = +year.textContent.replace(/[^]*(\d{4})[^]*/, '$1');
+ image = image.src;
+
+ // auto-prompt downloading for the user
+ let links = $('[class~="idtabs"i] [href^="#div"i]');
+
+ if(links.length > 1) {
+ OLOAD_EVENTS.push(setTimeout(
+ () => Notify('update', 'Finding download links...', 3000),
+ 500
+ ));
+
+ links.forEach((link, index, array) => OLOAD_EVENTS.push(setTimeout(
+ () => {
+ link.click();
+
+ if(index == links.length -1)
+ OLOAD_EVENTS.push(setTimeout(
+ () => Notify('update', 'No download links found'),
+ 7000
+ ));
+ },
+ index * 4500
+ )));
+ }
+
+ return { type, title, year, image };
+ },
+},
+ OLOAD_EVENTS = [];
+
+top.addEventListener('message', request => {
+ try {
+ request = request.data;
+
+ if(request)
+ if(request.from || request.found)
+ OLOAD_EVENTS.forEach(timeout => clearTimeout(timeout));
+ } catch(error) {
+ throw error;
+ }
+});
diff --git a/win/cloud/plugin/kitsu.js b/win/cloud/plugin/kitsu.js
new file mode 100644
index 0000000..c55730f
--- /dev/null
+++ b/win/cloud/plugin/kitsu.js
@@ -0,0 +1,36 @@
+// Web to Plex - Kitsu Plugin
+// Aurthor(s) - @ephellon (2019)
+let plugin = {
+ "url": "*://*.kitsu.io/anime/*",
+
+ "ready": () => !$('img[data-src][src]').empty,
+
+ "timeout": 1000,
+
+ "init": () => {
+ let _title = /^\s*(?:english|romanized)\s+(.+)\s*$/i,
+ _year = /^\s*aired\s+.+(\d{4})(?:\s+to.+)?\s*$/i;
+
+ let title = $('.media--information li').filter(e => _title.test(e.textContent))[0],
+ year = $('.media--information li').filter(e => _year.test(e.textContent))[0],
+ image = $('.media-poster img').first,
+ type = plugin.getType();
+
+ title = title.textContent.replace(_title, '$1');
+ year = +year.textContent.replace(_year, '$1');
+ image = image.src;
+
+ return {
+ type,
+ title,
+ year,
+ image
+ };
+ },
+
+ "getType": () => {
+ $('.media--information li').filter(e => /^\s*type\s+(movie|tv([\s\-]?show)?)\s*$/i.test(e.textContent));
+
+ return /tv/i.test(RegExp.$1)? 'show': 'movie';
+ },
+};
diff --git a/win/cloud/plugin/myanimelist.js b/win/cloud/plugin/myanimelist.js
new file mode 100644
index 0000000..aa36851
--- /dev/null
+++ b/win/cloud/plugin/myanimelist.js
@@ -0,0 +1,29 @@
+// Web to Plex - My Anime List Plugin
+// Aurthor(s) - @ephellon (2018)
+
+let plugin = {
+ "url": "*://*.myanimelist.net/anime/\\d+/*",
+
+ "init": () => {
+ let title = document.queryBy('table h2:nth-of-type(1) + *')
+ .first.textContent.replace(/^[^\:]+:/, '')
+ .trim(),
+ type = document.queryBy('table h2:nth-of-type(2) + *')
+ .first.textContent.trim()
+ .toLowerCase()
+ .split(/\s+/)
+ .reverse()[0],
+ year = +(document.queryBy('table h2:nth-of-type(2) ~ .spaceit ~ .spaceit')
+ .first.textContent.trim()
+ .replace(/[^]*(\d{4})[^]*/, '$1')),
+ image = document.queryBy('table img')
+ .first.src;
+
+ return {
+ type,
+ title,
+ year,
+ image
+ };
+ },
+};
diff --git a/win/cloud/plugin/myshows.js b/win/cloud/plugin/myshows.js
new file mode 100644
index 0000000..639660f
--- /dev/null
+++ b/win/cloud/plugin/myshows.js
@@ -0,0 +1,30 @@
+// Web to Plex - My Shows Plugin
+// Aurthor(s) - @enchained (2018)
+
+let plugin = {
+ "url": "*://*.myshows.me/view/\\d+/*",
+
+ "init": (ready) => {
+ let specific = /\/\/(\w{2})\./.test(location.origin);
+
+ let title = (
+ specific?
+ document.queryBy('h1[itemprop="name"]').first.textContent:
+ document.queryBy('main > h1').first.textContent
+ ).trim(),
+
+ year = +(document.queryBy('div.clear > p.flat')
+ .first.textContent.trim()
+ .replace(/[^]*?(\d{4})[^]*/, '$1')),
+
+ IMDbID = document.queryBy('[href*="/title/tt"]')
+ .first.href.replace(/[^]*(tt\d+)[^]*/, '$1');
+
+ return {
+ type: 'show',
+ title,
+ year,
+ IMDbID,
+ };
+ },
+};
diff --git a/win/cloud/plugin/redbox.js b/win/cloud/plugin/redbox.js
new file mode 100644
index 0000000..1724a3f
--- /dev/null
+++ b/win/cloud/plugin/redbox.js
@@ -0,0 +1,28 @@
+let plugin = {
+ "url": "*://*.redbox.com/(ondemand-)?(movies|tvshows)/(?!featured|$)",
+
+ "ready": () => !$('[data-test-id$="-name"i]').empty,
+
+ "timeout": 1000,
+
+ "init": (ready) => {
+ let R = RegExp;
+
+ let title = $('[data-test-id$="-name"i]').first,
+ year = $('[data-test-id$="-info"i]').first,
+ image = $('[data-test-id$="-img"i]').first,
+ type = plugin.getType();
+
+ title = title.textContent.replace(/\s*\((\d{4})\)/, '');
+ year = +(R.$1 || year.textContent.split(/\s*\|\s*/)[1]);
+ image = image.src;
+
+ return { type, title, year, image };
+ },
+
+ "getType": () => {
+ return /\bmovies\b/.test(location.pathname)?
+ 'movie':
+ 'show';
+ },
+};
diff --git a/win/cloud/plugin/shanaproject.js b/win/cloud/plugin/shanaproject.js
new file mode 100644
index 0000000..f358176
--- /dev/null
+++ b/win/cloud/plugin/shanaproject.js
@@ -0,0 +1,25 @@
+// Web to Plex - Shana Project Plugin
+// Aurthor(s) - @ephellon (2018)
+let plugin = {
+ "url": "*://*.shanaproject.com/series/\\d+",
+
+ "init": () => {
+ let title = $('.overview i, #header_big .header_info_block')
+ .first.textContent.trim(),
+ year = +($('#header_big .header_info_block + *')
+ .first.textContent.trim()
+ .replace(/[^]*(\d{4})[^]*/m, '$1')),
+ image = $('#header_big .header_display_box')
+ .first.style['background-image'].trim()
+ .replace(/url\((.+)\)/i, '$1');
+
+ title = title.replace(RegExp(`\\s*\\(${ year }\\)`), '');
+
+ return {
+ type: 'show',
+ title,
+ year,
+ image
+ };
+ },
+};
diff --git a/win/cloud/plugin/snagfilms.js b/win/cloud/plugin/snagfilms.js
new file mode 100644
index 0000000..697b4a1
--- /dev/null
+++ b/win/cloud/plugin/snagfilms.js
@@ -0,0 +1,34 @@
+let plugin = {
+ "url": "*://*.snagfilms.com/(films?|shows?)/*",
+
+ "ready": () => !$('[itemprop~="genre"i], .show .title').empty,
+
+ "timeout": 1000,
+
+ "init": (ready) => {
+ let R = RegExp;
+
+ let title, year, image,
+ type = plugin.getType();
+
+ if(type == 'movie') {
+ title = $('.header-title').first;
+ year = $('[itemprop~="genre"i]').first.previousElementSibling;
+
+ title = title.textContent;
+ year = +(year.textContent.replace(/\W+/g, ''))
+ } else if(type == 'show') {
+ title = $('.title').first;
+
+ title = title.textContent;
+ }
+
+ return { type, title, year, image };
+ },
+
+ "getType": () => {
+ return /\bfilms?\b/.test(location.pathname)?
+ 'movie':
+ 'show';
+ },
+};
diff --git a/win/cloud/plugin/toloka.js b/win/cloud/plugin/toloka.js
new file mode 100644
index 0000000..db5f6dd
--- /dev/null
+++ b/win/cloud/plugin/toloka.js
@@ -0,0 +1,53 @@
+// Web to Plex - Toloka Plugin
+// Aurthor(s) - @chmez (2017)
+/* Minimal Required Layout *
+ plugin {
+ url: string,
+ init: function => ({ type:string, title:string, year:number|null|undefined })
+ }
+*/
+// REQUIRED [plugin:object]: The plugin object
+let plugin = {
+ // REQUIRED [plugin.url]: this is what you ask Web to Plex access to; currently limited to a single domain
+ "url": "*://*.toloka.to/*",
+
+ // REQUIRED [plugin.init]: this is what Web to Plex will call on when the url is detected
+ // it will always be fired after the page and Web to Plex have been loaded
+ "init": () => {
+ let title = document.queryBy('.maintitle')
+ .first.textContent.replace(/^.+\/(.+?)\(([\d]{4})\)\s*$/, '$1')
+ .trim(),
+ // REQUIRED [title:string]
+ // you have access to the exposed "helper.js" file within the extension
+
+ year = +RegExp.$2,
+ // PREFERRED [year:number, null, undefined]
+
+ image = document.queryBy('.postbody img')
+ .first.src,
+ // OPTIONAL [image:string]
+
+ IMDbID = plugin.getID();
+
+ // the rest of the code is up to you, but should be limited to a layout similar to this
+ // REQUIRED [{ type:'movie', 'show'; title:string; year:number }]
+ // PREFERRED [{ image:string; IMDbID:string; TMDbID:string, number; TVDbID:string, number }]
+ return {
+ type: 'movie',
+ title,
+ year,
+ image,
+ IMDbID
+ };
+ },
+
+ // OPTIONAL: the rest of this code is purely for functionality
+ "getID": () => {
+ let links = document.queryBy('.postlink'),
+ regex = /^https?\:\/\/(?:w{3}\.)?imdb\.com\/title\/(tt\d+)/i;
+
+ for(let link in links)
+ if(regex.test(links[link]))
+ return RegExp.$1;
+ }
+};
diff --git a/win/cloud/rottentomatoes.js b/win/cloud/rottentomatoes.js
new file mode 100644
index 0000000..f06644b
--- /dev/null
+++ b/win/cloud/rottentomatoes.js
@@ -0,0 +1,86 @@
+let script = {
+ "url": "*://*.rottentomatoes.com/([mt]|browse)/*",
+
+ "ready": () => {
+ let element = $('#reviews').first;
+
+ return !!element;
+ },
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let title, type, year, image;
+
+ type = script.getType();
+
+ switch(type) {
+ case 'movie':
+ case 'show':
+ title = $('.playButton + .title, [itemprop="name"], [class*="wrap__title" i]').first;
+ year = $('time').first;
+ image = $('[class*="posterimage" i]').first;
+
+ if(!title)
+ return 1000;
+
+ title = title.textContent.trim().replace(/(.+)\:[^]*$/, type == 'movie'? '$&': '$1');
+ year = +year.textContent.replace(/[^]*(\d{4})/, '').trim();
+ image = (image || {}).srcset;
+
+ if(image)
+ image = image.replace(/([^\s]+)[^]*/, '$1');
+
+ return { type, title, year, image };
+ break;
+
+ case 'list':
+ let options = [],
+ elements = $('.mb-movie');
+
+ elements.forEach((element, index, array) => {
+ let option = script.process(element);
+
+ if(option)
+ options.push(option);
+ });
+
+ return options;
+ break;
+
+ default:
+ return 1000;
+ break;
+ }
+ },
+
+ "getType": () => {
+ let { pathname } = top.location;
+
+ return (/^\/browse\/i/.test(pathname))?
+ 'list':
+ (/^\/m/.test(pathname))?
+ 'movie':
+ (/^\/t/.test(pathname))?
+ 'show':
+ 'error';
+ },
+
+ "process": (element) => {
+ let title = $('.movieTitle').first,
+ image = $('.poster').first,
+ type = $('[href^="/m/"], [href^="/t/"]').first;
+
+ title = title.textContent.trim();
+ image = image.src;
+ type = /\/([mt])\//i.test(type.href)? RegExp.$1 == 'm'? 'movie': 'show': null;
+
+ if(!type)
+ return {};
+
+ if(type == 'show')
+ title = title.replace(/\s*\:\s*seasons?\s+\d+\s*/i, '');
+
+ return { type, title, image };
+ },
+};
diff --git a/win/cloud/tmdb.js b/win/cloud/tmdb.js
new file mode 100644
index 0000000..d4d7163
--- /dev/null
+++ b/win/cloud/tmdb.js
@@ -0,0 +1,79 @@
+let script = {
+ "url": "*://*.themoviedb.org/(movie|tv)/\\d+",
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let type = script.getType(),
+ TMDbID = script.getTMDbID(),
+ title, year, image;
+
+ let options;
+
+ switch(type) {
+ case 'movie':
+ case 'tv':
+ title = $('.title > span > *:not(.release_date)').first;
+ year = $('.title .release_date').first;
+ image = $('img.poster').first;
+
+ title = title.textContent.trim();
+ year = +year.textContent.replace(/\(|\)/g, '').trim();
+ image = (image || {}).src;
+
+ if(type != 'movie')
+ type = 'show';
+
+ options = { type, title, year, image, TMDbID };
+ break;
+
+ case 'list':
+ let items = $('.item.card');
+
+ options = [];
+
+ items.forEach(element => {
+ let option = script.process(element);
+
+ if(option)
+ options.push(option);
+ });
+ break;
+
+ default: return null;
+ }
+
+ return options;
+ },
+
+ "getType": () => {
+ let { pathname } = top.location;
+
+ return (/\/(movie|tv)\/\d+/.test(pathname))?
+ RegExp.$1:
+ (/(^\/discover\/|\/(movie|tv)\/([^\d]+|\B))/i.test(pathname))?
+ 'list':
+ 'error';
+ },
+
+ "getTMDbID": () => {
+ return +top.location.pathname.replace(/\/(?:movie|tv)\/(\d+).*/, '$1');
+ },
+
+ "process": (element) => {
+ let title = $('.title').first,
+ year = $('.title + *').first,
+ image = $('.poster').first,
+ type = title.id.split('_'),
+ TMDbID = +type[1];
+
+ title = title.textContent.trim();
+ year = year.textContent;
+ image = image.src;
+ type = (type[0] == 'movie'? 'movie': 'show');
+
+ year = +year;
+
+ return { type, title, year, image, TMDbID };
+ },
+};
diff --git a/win/cloud/trakt.js b/win/cloud/trakt.js
new file mode 100644
index 0000000..77c531d
--- /dev/null
+++ b/win/cloud/trakt.js
@@ -0,0 +1,104 @@
+/** TODO
+ - re-enable list functionality (fix it)
+**/
+
+let script = {
+ "url": "*://*.trakt.tv/(movie|show)s/*",
+
+ "ready": () => !$('#info-wrapper ul.external, .format-date').empty,
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let type = script.getType(),
+ IMDbID, TMDbID, TVDbID,
+ title, year, image, options;
+
+ switch(type) {
+ case 'movie':
+ case 'show':
+ title = $('.mobile-title').first;
+ year = $('.mobile-title .year').first;
+ image = $('.poster img.real[alt="poster"i]').first;
+ IMDbID = script.getIMDbID();
+ TMDbID = script.getTMDbID();
+ TVDbID = script.getTVDbID();
+
+ if(!IMDbID && !TMDbID && !TVDbID)
+ return 5000;
+
+ title = title.textContent.replace(/(.+)(\d{4}).*?$/, '$1').replace(/\s*\:\s*Season.*$/i, '').trim();
+ year = +(R.$2 || year.textContent).trim();
+ image = (image || {}).src;
+
+ options = { type, title, year, image, IMDbID, TMDbID, TVDbID };
+ break;
+
+ case 'list':
+ let items = $('*');
+
+ options = [];
+
+ items.forEach((element, index, array) => {
+ let option = script.process(element, items);
+
+ if(option)
+ options.push(option);
+ });
+ break;
+
+ default:
+ return null;
+ }
+
+ return options;
+ },
+
+ "getType": () => {
+ let { pathname } = top.location;
+
+ return (
+ // /^\/(dashboard|calendars|people|search|(?:movie|show)s?\/(?:trending|popular|watched|collected|anticipated|boxoffice)|$)/i.test(pathname)?
+ // 'list':
+ /^\/(movie|show)s\//i.test(pathname)?
+ RegExp.$1:
+ 'error'
+ )
+ },
+
+ "getIMDbID": () => {
+ let link = $(
+ // HTTPS and HTTP
+ '[href*="imdb.com/title/tt"]'
+ ).first;
+
+ if(link)
+ return link.href.replace(/^.*?imdb\.com\/.+\b(tt\d+)\b/, '$1');
+ },
+
+ "getTMDbID": () => {
+ let link = $(
+ // HTTPS and HTTP
+ '[href*="themoviedb.org/"]'
+ ).first;
+
+ if(link)
+ return link.href.replace(/^.*?themoviedb.org\/(?:movie|tv|shows?|series)\/(\d+).*?$/, '$1');
+ },
+
+ "getTVDbID": () => {
+ let link = $(
+ // HTTPS and HTTP
+ '[href*="thetvdb.com/"]'
+ ).first;
+
+ if(link)
+ return link.href.replace(/^.*?thetvdb.com\/.+\/(\d+)\b.*?$/, '$1');
+ },
+
+ "process": (element, elements) => {
+ let type, title, year;
+
+ return { type, title, year };
+ },
+};
diff --git a/win/cloud/tubi.js b/win/cloud/tubi.js
new file mode 100644
index 0000000..7ede144
--- /dev/null
+++ b/win/cloud/tubi.js
@@ -0,0 +1,22 @@
+let script = {
+ "url": "*://*.tubitv.com/(movies|series)/\\d+/*",
+
+ "timeout": 1000,
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let title = $('._1mbQP').first,
+ year = $('._3BhXb').first,
+ image = $('._2TykB').first,
+ type = script.getType(); // described below
+
+ title = title.textContent.trim();
+ year = +year.textContent.replace(/[^]*\((\d+)\)[^]*/g, '$1').trim();
+ image = image.getAttribute('style').replace(/[^]+url\('([^]+?)'\)/, '$1');
+
+ return { type, title, year, image };
+ },
+
+ "getType": () => (/^\/movies?/.test(top.location.pathname)? 'movie': 'show'),
+};
diff --git a/win/cloud/tvdb.js b/win/cloud/tvdb.js
new file mode 100644
index 0000000..ba29377
--- /dev/null
+++ b/win/cloud/tvdb.js
@@ -0,0 +1,44 @@
+let script = {
+ "url": "*://*.thetvdb.com/series/*",
+
+ "ready": () => !$('#series_basic_info').empty,
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let title = $('#series_title, .translated_title').first,
+ image = $('img[src*="/posters/"]').first,
+ type = 'show',
+ TVDbID = script.getTVDbID(),
+ Db = {}, year;
+
+ title = title.textContent.trim();
+ image = (image || {}).src;
+
+ $('#series_basic_info').first.textContent
+ .replace(/^\s+|\s+$/g, '')
+ .replace(/^\s+$/gm, '')
+ .replace(/^\s+(\S)/gm, '$1')
+ .split(RegExp(`\\n*\\n*`))
+ .forEach(value => {
+ value = value.split(/\n+/, 2);
+
+ let n = value[0], v = value[1];
+
+ n = n.replace(/^([\w\s]+).*$/, '$1').replace(/\s+/g, '_').toLowerCase();
+
+ Db[n] = /,/.test(v)? v.split(/\s*,\s*/): v;
+ });
+
+ year = +(((Db.first_aired || YEAR) + '').slice(0, 4));
+
+ return { type, title, year, image, TVDbID };
+ },
+
+ "getTVDbID": () => {
+ let { pathname } = top.location;
+
+ if(/\/series\/(\d+)/.test(pathname))
+ return RegExp.$1;
+ },
+};
diff --git a/win/cloud/tvmaze.js b/win/cloud/tvmaze.js
new file mode 100644
index 0000000..3332237
--- /dev/null
+++ b/win/cloud/tvmaze.js
@@ -0,0 +1,27 @@
+let script = {
+ "url": "*://*.tvmaze.com/shows/*",
+
+ "ready": () => !$('#general-info-panel .rateit').empty,
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let title = $('header.columns > h1').first,
+ year = $('#year').first,
+ image = $('figure img').first,
+ type = 'show',
+ TVDbID = script.getTVDbID();
+
+ title = title.textContent.trim();
+ year = +year.textContent.replace(/\((\d+).+\)/, '$1');
+ image = (image || {}).src;
+
+ return { type, title, year, image, TVDbID };
+ },
+
+ "getTVDbID": () => {
+ let { pathname } = top.location;
+
+ return pathname.replace(/\/shows\/(\d+).*/, '$1');
+ },
+};
diff --git a/win/cloud/verizon.js b/win/cloud/verizon.js
new file mode 100644
index 0000000..8a89d99
--- /dev/null
+++ b/win/cloud/verizon.js
@@ -0,0 +1,58 @@
+let script = {
+ "url": "*://*.verizon.com/*/(movie|show)s?/*",
+
+ "ready": () => !$('.container .btn-with-play, .moredetails, .more-like').empty,
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let image = $('.cover img').first,
+ type = script.getType(),
+ title, year;
+
+ if(script.ondemand) {
+ if(type == 'movie') {
+ title = $('.detail *').first;
+ year = $('.rating *').first;
+ } else if(type == 'show') {
+ title = { textContent: top.location.pathname.replace(/\/ondemand\/tvshows?\/([^\/]+?)\/.*/i) };
+ year = $('#showDetails > * > *:nth-child(4) *:last-child').first;
+
+ title.textContent = decodeURL(title.textContent).toCpas();
+ } else {
+ return null;
+ }
+ } else if(script.watch) {
+ title = $('[class*="title__"]').first;
+ year = $('[class*="subtitle__"]').first;
+ } else {
+ title = $('.copy > .title').first;
+ year = (type == 'movie')?
+ $('.copy > .details').first:
+ $('.summary ~ .title ~ *').first;
+ }
+
+ if(!title)
+ return 1000;
+
+ year = +year.textContent.slice(0, 4).trim();
+ title = title.textContent.replace(RegExp(`\\s*\\(${ year }\\).*`), '').trim();
+ image = (image || {}).src;
+
+ return { type, title, year, image };
+ },
+
+ "getType": () => {
+ let { pathname } = top.location;
+
+ return /\bmovies?\b/i.test(pathname)?
+ 'movie':
+ /\bseries\b/i.test(pathname)?
+ 'show':
+ 'error'
+ },
+
+ ondemand: /\bondemand\b/i.test(top.location.pathname),
+
+ watch: /\bwatch\b/i.test(top.location.pathname),
+};
diff --git a/win/cloud/vrv.js b/win/cloud/vrv.js
new file mode 100644
index 0000000..1e1f7af
--- /dev/null
+++ b/win/cloud/vrv.js
@@ -0,0 +1,80 @@
+let script = {
+ "url": "*://*.vrv.co/(series|watch)/",
+
+ "ready": () => {
+ let img = $('.h-thumbnail > img').first,
+ pre = $('#content .content .card').first;
+
+ return script.getType('list')? pre && pre.textContent: img && img.src;
+ },
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let type = script.getType(),
+ title, year, image, options;
+
+ switch(type) {
+ case 'movie':
+ case 'show':
+ title = $('.series, .series-title, .video-title, [class*="series"] .title, [class*="video"] .title').first;
+ year = $('.additional-information-item').first;
+ image = $('.series-poster img').first;
+
+ title = title.textContent.replace(/(unrated|mature|tv-?\d{1,2})\s*$/i, '').trim();
+ year = year? +year.textContent.replace(/.+(\d{4}).*/, '$1').trim(): 0;
+ image = (image || {}).src;
+
+ options = { type, title, year, image };
+ break;
+
+ case 'list':
+ let items = $('#content .content .card');
+
+ options = [];
+
+ items.forEach(element => {
+ let option = script.process(element);
+
+ if(option)
+ options.push(option);
+ });
+ break;
+
+ default:
+ return 5000;
+ }
+
+ return options;
+ },
+
+ "getType": (expected) => {
+ let type = 'error',
+ { pathname } = top.location;
+
+ type = (/^\/(?:series)\//.test(pathname) || (/^\/(?:watch)\//.test(pathname) && !$('.content .series').empty))?
+ 'show':
+ (/^\/(?:watch)\//.test(pathname) && $('.content .series').empty)?
+ 'movie':
+ (/\/(watchlist)\b/i.test(pathname))?
+ 'list':
+ type;
+
+ if(expected)
+ return type == expected;
+
+ return type;
+ },
+
+ "process": (element) => {
+ let title = $('.info > *', element).first,
+ image = $('.poster-image img', element).first,
+ type = $('.info [class*="series"], .info [class*="movie"]', element).first;
+
+ title = title.textContent.trim();
+ image = image.src;
+ type = type.getAttribute('class').replace(/[^]*(movie|series)[^]*/, '$1');
+
+ return { type, title, image };
+ },
+};
diff --git a/win/cloud/vudu.js b/win/cloud/vudu.js
new file mode 100644
index 0000000..15af40a
--- /dev/null
+++ b/win/cloud/vudu.js
@@ -0,0 +1,32 @@
+let script = {
+ "url": "*://*.vudu.com/*",
+
+ "ready": () => !$('img[src*="poster" i]').empty,
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let title = $('.head-big').first,
+ year = $('.container .row:first-child .row ~ * > .row span').first,
+ image = $('img[src*="poster" i]').first,
+ type = script.getType();
+
+ title = title.textContent.replace(/\((\d{4})\)/, '').trim();
+ year = year? year.textContent.split(/\s*\|\s*/): R.$1;
+ image = (image || {}).src;
+
+ if(!title)
+ return 5000;
+
+ year = +year[year.length - 1].slice(0, 4);
+ year |= 0;
+
+ return { type, title, year, image };
+ },
+
+ "getType": () => {
+ return /(?:Season-\d+\/\d+)$/i.test(window.location.pathname)?
+ 'show':
+ 'movie';
+ },
+};
diff --git a/win/cloud/vumoo.js b/win/cloud/vumoo.js
new file mode 100644
index 0000000..1a32142
--- /dev/null
+++ b/win/cloud/vumoo.js
@@ -0,0 +1,68 @@
+let script = {
+ "url": "*://*.vumoo.to/(movies|tv-series)/*",
+
+ "ready": () => !$('[role="presentation"i]').empty,
+
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let title = $('.film-box h1').first,
+ year = $('.film-box > * span').filter(e => /\b\d{4}\b/.test(e.textContent))[0],
+ image = $('.poster').first,
+ type = script.getType();
+
+ title = title.textContent.replace(/\s*season\s+\d+\s*$/i, '').replace(/\s*\((\d{4})\)/, '').trim();
+ year = +(type == 'movie')?
+ R.$1:
+ year.textContent.replace(/[^]*(\d{4})[^]*/, '$1');
+ image = (image? image.src: null);
+
+ // auto-prompt downloading for the user
+ let servers = $('.play'),
+ roles = $('[role="presentation"i] a');
+
+ if(servers.length > 1 && type != 'show') {
+ OLOAD_EVENTS.push(setTimeout(
+ () => Notify('update', 'Finding download links...', 3000),
+ 500
+ ));
+
+ servers.forEach((server, index, array) => OLOAD_EVENTS.push(setTimeout(
+ () => {
+ roles[index].click();
+ server.click();
+
+ if(index == servers.length -1)
+ OLOAD_EVENTS.push(setTimeout(
+ () => Notify('update', 'No download links found'),
+ 7000
+ ));
+ },
+ index * 4500
+ )));
+ }
+
+ return { type, title, year, image };
+ },
+
+ "getType": () => {
+ let { pathname } = top.location;
+
+ return pathname.startsWith('/movies')?
+ 'movie':
+ 'show';
+ },
+},
+ OLOAD_EVENTS = [];
+
+top.addEventListener('message', request => {
+ try {
+ request = request.data;
+
+ if(request)
+ if(request.from == 'oload' || request.found == true)
+ OLOAD_EVENTS.forEach(timeout => clearTimeout(timeout));
+ } catch(error) {
+ throw error;
+ }
+});
diff --git a/win/cloud/webtoplex.js b/win/cloud/webtoplex.js
new file mode 100644
index 0000000..a20f9af
--- /dev/null
+++ b/win/cloud/webtoplex.js
@@ -0,0 +1,57 @@
+// optional
+// "Web to Plex" requires: token
+// 'Friendly Name' requires permissions...
+
+let script = {
+ // required
+ "url": "*://ephellon.github.io/web.to.plex/(?!test|login)",
+ // Example: *://*.amazon.com/*/video/(detail|buy)/*
+ // *:// - match any protocol (http, https, etc.)
+ // *.amazon.com - match any sub-domain (www, ww5, etc.)
+ // /* - match any path
+ // (detail|buy) - match one of the items
+
+ // optional
+ "ready": () => location.search && location.search.length > 1 && $('#tmdb').first.textContent,
+
+ // optional
+ "timeout": 5000, // if the script fails to complete, retry after ... milliseconds
+
+ // required
+ "init": (ready) => {
+ let _title, _year, _image, R = RegExp;
+
+ let title = $('#title').first,
+ year = $('#year').first,
+ image = $('#poster').first,
+ type = script.getType(), // described below
+ IMDbID = script.getID('imdb')||"",
+ TMDbID = script.getID('tmdb')|0;
+
+ title = title.textContent;
+ year = year.textContent|0;
+ image = image.src;
+
+ return { type, title, year, image, IMDbID, TMDbID };
+ },
+
+ // optional | functionality only
+ "getType": () => ($('#info').first.getAttribute('type') == 'movie'? 'movie': 'show'),
+
+ "getID": (provider) => $(`#${provider}`).first.textContent,
+};
+
+setTimeout(() => {
+ let login = /\blogin\b/.test(location.pathname),
+ apikey = $('#apikey').first;
+
+ if(login && configuration.TMDbAPI && !apikey.value) {
+ apikey.value = configuration.TMDbAPI;
+
+ return -1;
+ // no longer needed to run
+ } else if(login) {
+ return -1;
+ // don't run on the login page
+ }
+}, 100);
diff --git a/win/cloud/youtube.js b/win/cloud/youtube.js
new file mode 100644
index 0000000..1907ba6
--- /dev/null
+++ b/win/cloud/youtube.js
@@ -0,0 +1,137 @@
+let openedByUser = false,
+ listenersSet = false,
+ listenerInt;
+
+let script = {
+ "url": "*://www.youtube.com/.+",
+
+ "timeout": 1000,
+
+ "init": (ready, rerun = false) => {
+ let _title, _year, _image, R = RegExp;
+
+ let options, type,
+ alternative = $('#offer-module-container[class*="movie-offer"], #offer-module-container[class*="unlimited-offer"]');
+
+ if($('.more-button:not(span), .less-button').empty || !$('.opened').empty || !$('iron-dropdown[class*="ytd"][aria-hidden]').empty)
+ return script.timeout;
+
+ // open and close the meta-information
+ // open
+ $('.more-button:not(span)').first.click();
+ // close
+ setTimeout(() => $('.less-button').first.click(), script.timeout);
+
+ // try to not bug the page content too much, use an alternative method first (if applicable)
+ if(!alternative.empty && !rerun) {
+ alternative = alternative.first;
+
+ let title = $('#title', alternative).first,
+ year = $('#info p', alternative).child(2).lastElementChild,
+ image = $('#img img', alternative).first,
+ type = /\bmovie-offer\b/i.test(alternative.classList)? 'movie': 'show';
+
+ if(!title || !year)
+ return -1;
+
+ title = title.textContent;
+ year = year.textContent|0;
+ image = image.src;
+
+ title = title.replace(R(`\\s*(\\(\\s*)?${ year }\\s*(\\))?`), '');
+
+ return { type, title, year, image };
+ }
+
+ type = script.getType();
+
+ if(type == 'error')
+ return -1;
+
+ if(type == 'movie' || type == 'show') {
+ let title = $((type == 'movie'? '.title': '#owner-container, #header #main-title')).first,
+ year = $('#content ytd-expander').first,
+ image = $('#img img').first || { src: '' };
+
+ if(!title)
+ return -1;
+
+ title = title.textContent.trim();
+ year = (year)?
+ +year.textContent.replace(/[^]*(?:release|air) date\s+(?:(?:\d+\/\d+\/)?(\d{2,4}))[^]*/i, ($0, $1, $$, $_) => +$1 < 1000? 2000 + +$1: $1):
+ YEAR;
+ image = image.src;
+
+ title = title.replace(R(`\\s*(\\(\\s*)?${ year }\\s*(\\))?`), '');
+
+ options = { type, title, year, image };
+ } else if(type == 'list') {
+ let title = $('#title').first,
+ year = $('#stats *').child(2),
+ image = $('#thumbnail #img').first;
+
+ if(!title)
+ return -1;
+
+ title = title.textContent.trim();
+ year = parseInt(year.textContent);
+ image = (image || {}).src;
+ type = 'show';
+
+ options = { type, title, year, image };
+ } else {
+ return -1;
+ }
+
+ if(!listenersSet) {
+ listenerInt = setInterval(() => {
+ let closed = 'collapsed' in $('ytd-expander').first.attributes;
+
+ if(closed && !openedByUser)
+ script.init(true);
+ }, 10);
+
+ $('ytd-expander').first.addEventListener('mouseup', event => {
+ let closed = 'collapsed' in $('ytd-expander').first.attributes;
+
+ if(!closed)
+ openedByUser = true;
+ else
+ openedByUser = false;
+ });
+
+ listenersSet = true;
+ } else {
+ clearInterval(listenerInt);
+ }
+
+ return options;
+ },
+
+ "getType": () => {
+ let title = $('.super-title, #title, #header #main-title').filter(e => e.textContent)[0],
+ subtitle = $('#header #main-title + #sub-title').filter(e => e.textContent)[0],
+ owner = $('#owner-container, #upload-info [href^="/channel/"]');
+
+ if(owner.empty)
+ return 'error';
+ else
+ owner = owner.first.textContent.replace(/^\s+|\s+$/g, '');
+
+ let R = {
+ movie: /\byoutube movies\b/i,
+ show : /\b(s\d+\b.+\be\d+|season \d+)\b/i,
+ list : /\/playlist\b/,
+ };
+
+ return (R.movie.test(owner))?
+ 'movie':
+ ((title && R.show.test(title.textContent)) || (subtitle && R.show.test(subtitle.textContent)))?
+ 'show':
+ (title && R.list.test(top.location.pathname))?
+ 'list':
+ 'error';
+ },
+};
+
+// $('a[href*="/watch?v="]').forEach(element => element.onclick = event => open(event.target.href, '_self'));
diff --git a/win/download/consistent.js b/win/download/consistent.js
new file mode 100644
index 0000000..d688b5b
--- /dev/null
+++ b/win/download/consistent.js
@@ -0,0 +1,29 @@
+let NO_DEBUGGER = false;
+
+let terminal =
+ NO_DEBUGGER?
+ { error: m => m, info: m => m, log: m => m, warn: m => m, group: m => m, groupEnd: m => m }:
+ console;
+
+let check;
+
+check = document.body.onload = event => {
+ let video = document.querySelector('video');
+
+ if(video && (video.src || video.textContent)) {
+ let { src } = video;
+
+ src = src || video.textContent;
+
+ if(/^blob:/i.test(src))
+ throw ' URL detected. Unable to reform file.';
+
+ try {
+ top.postMessage({ href: src, tail: 'MP4', type: 'SEND_VIDEO_LINK', from: 'consistent' }, '*');
+ } catch(error) {
+ terminal.error('Failed to post message:', error);
+ }
+ } else {
+ setTimeout(check, 500);
+ }
+};
diff --git a/win/download/fembed.js b/win/download/fembed.js
new file mode 100644
index 0000000..075275e
--- /dev/null
+++ b/win/download/fembed.js
@@ -0,0 +1,29 @@
+let NO_DEBUGGER = false;
+
+let terminal =
+ NO_DEBUGGER?
+ { error: m => m, info: m => m, log: m => m, warn: m => m, group: m => m, groupEnd: m => m }:
+ console;
+
+let check;
+
+check = document.body.onload = event => {
+ let video = document.querySelector('video');
+
+ if(video && (video.src || video.textContent)) {
+ let { src } = video;
+
+ src = src || video.textContent;
+
+ if(/^blob:/i.test(src))
+ throw ' URL detected. Unable to reform file.';
+
+ try {
+ top.postMessage({ href: src, tail: 'MP4', type: 'SEND_VIDEO_LINK', from: 'fembed' }, '*');
+ } catch(error) {
+ terminal.error('Failed to post message:', error);
+ }
+ } else {
+ setTimeout(check, 500);
+ }
+};
diff --git a/win/download/gounlimited.js b/win/download/gounlimited.js
new file mode 100644
index 0000000..fdbcdd7
--- /dev/null
+++ b/win/download/gounlimited.js
@@ -0,0 +1,29 @@
+let NO_DEBUGGER = false;
+
+let terminal =
+ NO_DEBUGGER?
+ { error: m => m, info: m => m, log: m => m, warn: m => m, group: m => m, groupEnd: m => m }:
+ console;
+
+let check;
+
+check = document.body.onload = event => {
+ let video = document.querySelector('video');
+
+ if(video && (video.src || video.textContent)) {
+ let { src } = video;
+
+ src = src || video.textContent;
+
+ if(/^blob:/i.test(src))
+ throw ' URL detected. Unable to reform file.';
+
+ try {
+ top.postMessage({ href: src, tail: 'MP4', type: 'SEND_VIDEO_LINK', from: 'gounlimited' }, '*');
+ } catch(error) {
+ terminal.error('Failed to post message:', error);
+ }
+ } else {
+ setTimeout(check, 500);
+ }
+};
diff --git a/win/download/oload.js b/win/download/oload.js
new file mode 100644
index 0000000..732f588
--- /dev/null
+++ b/win/download/oload.js
@@ -0,0 +1,29 @@
+let NO_DEBUGGER = false;
+
+let terminal =
+ NO_DEBUGGER?
+ { error: m => m, info: m => m, log: m => m, warn: m => m, group: m => m, groupEnd: m => m }:
+ console;
+
+let check;
+
+check = document.body.onload = event => {
+ let video = document.querySelector('div > p + p');
+
+ if(video && (video.src || video.textContent)) {
+ let { src } = video;
+
+ src = src || video.textContent;
+
+ if(/^blob:/i.test(src))
+ throw ' URL detected. Unable to reform file.';
+
+ try {
+ top.postMessage({ href: `https://oload.fun/stream/${ src }?mime=true`, tail: 'MP4', type: 'SEND_VIDEO_LINK', from: 'oload' }, '*');
+ } catch(error) {
+ terminal.error('Failed to post message:', error);
+ }
+ } else {
+ setTimeout(check, 500);
+ }
+};
diff --git a/win/download/plex.js b/win/download/plex.js
new file mode 100644
index 0000000..86fab87
--- /dev/null
+++ b/win/download/plex.js
@@ -0,0 +1,146 @@
+/** plxdwnld - Pip Longrun / Ephellon
+*
+* This project is licensed under the terms of the MIT license, see https://piplongrun.github.io/plxdwnld/LICENSE.txt
+*
+* @author Pip Longrun
+* @version 0.2
+* @see https://piplongrun.github.io/plxdwnld/
+*
+*/
+
+let plxdwnld = (() => {
+ let self = {}, R = RegExp,
+ baseURI, AccessToken,
+ RegExps = {
+ clientID: /server\/([a-f\d]{40})\//i,
+ metadataID: /key=%2Flibrary%2Fmetadata%2F(\d+)/i,
+ },
+ URLExps = {
+ API_resource: 'https://plex.tv/api/resources?includeHttps=1&X-Plex-Token={token}',
+ API_library: '{baseuri}/library/metadata/{id}?X-Plex-Token={token}',
+ download: '{baseuri}{partkey}?download=1&X-Plex-Token={token}',
+ },
+ access_token_path = '//Device[@clientIdentifier=\'{clientID}\']/@accessToken',
+ base_uri_path = '//Device[@clientIdentifier=\'{clientID}\']/Connection[@local=0]/@uri',
+ part_key_path = '//Media/Part[1]/@key';
+
+ // Errors
+ let ERROR = {
+ EMPTY: 'No response data was received',
+ NOT_PLEX: 'You are not browsing (or logged into) Plex',
+ NOT_MEDIA: 'You are not viewing a media item',
+ INVALID_TOKEN: 'Unable to find a valid Access Token',
+ };
+
+ let getXML = (url, callback) => {
+ fetch(`//cors-anywhere.herokuapp.com/${ url }`, { method: 'GET', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
+ .then(Q => Q.text())
+ .then(text => {
+ if(!text.length)
+ throw ERROR.EMPTY;
+
+ let Parser = new DOMParser(),
+ XML = Parser.parseFromString(text, 'text/xml');
+
+ callback(XML);
+ })
+ .catch(error => { throw error });
+ };
+
+ let getMetadata = (XML) => {
+ let clientID = RegExps.clientID.test(location.href)?
+ R.$1:
+ null;
+
+ if(clientID) {
+ let access_token_node = XML.evaluate(
+ access_token_path.replace(/{clientid}/ig, clientID),
+ XML,
+ null,
+ XPathResult.FIRST_ORDERED_NODE_TYPE,
+ null
+ ),
+ base_uri_node = XML.evaluate(
+ base_uri_path.replace(/{clientid}/ig, clientID),
+ XML,
+ null,
+ XPathResult.FIRST_ORDERED_NODE_TYPE,
+ null
+ );
+
+ if(access_token_node.singleNodeValue && base_uri_node.singleNodeValue) {
+ AccessToken = access_token_node.singleNodeValue.textContent;
+ baseURI = base_uri_node.singleNodeValue.textContent;
+
+ let metadataID = RegExps.metadataID.test(location.href)?
+ R.$1:
+ null;
+
+ if(metadataID)
+ getXML(
+ URLExps.API_library
+ .replace(/{baseuri}/ig, baseURI)
+ .replace(/{id}/ig, metadataID)
+ .replace(/{token}/ig, AccessToken)
+ , GetDownloadURL
+ );
+ else
+ throw ERROR.NOT_MEDIA;
+ } else {
+ throw ERROR.INVALID_TOKEN;
+ }
+ } else {
+ throw ERROR.NOT_MEDIA;
+ }
+ };
+
+ let GetDownloadURL = (XML) => {
+ let part_key_node = XML.evaluate(part_key_path, XML, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
+
+ if(part_key_node.singleNodeValue) {
+ let href = URLExps.download
+ .replace(/{baseuri}/ig, baseURI)
+ .replace(/{partkey}/ig, part_key_node.singleNodeValue.textContent)
+ .replace(/{token}/ig, AccessToken);
+
+ top.postMessage({ href, tail: 'MP4', type: 'SEND_VIDEO_LINK', from: 'plex' }, '*');
+ } else {
+ throw ERROR.NOT_MEDIA;
+ }
+ };
+
+ self.init = () => {
+ if(localStorage.myPlexAccessToken !== undefined)
+ getXML(URLExps.API_resource.replace(/{token}/ig, localStorage.myPlexAccessToken), getMetadata);
+ else
+ throw ERROR.NOT_PLEX;
+ };
+
+ return self;
+})();
+
+let NO_DEBUGGER = false;
+
+let terminal =
+ NO_DEBUGGER?
+ { error: m => m, info: m => m, log: m => m, warn: m => m, group: m => m, groupEnd: m => m }:
+ console;
+
+let check;
+
+check = document.body.onload = event => {
+ let loading = document.querySelector('.loading');
+
+ if(!loading) {
+ setTimeout(() => {
+ try {
+ plxdwnld.init();
+ } catch(error) {
+ terminal.error('Failed to post message:', error);
+ setTimeout(check, 5000);
+ }
+ }, 5000)
+ } else {
+ setTimeout(check, 500);
+ }
+};
diff --git a/win/helpers.js b/win/helpers.js
new file mode 100644
index 0000000..9ac30a1
--- /dev/null
+++ b/win/helpers.js
@@ -0,0 +1,57 @@
+async function load(name = '') {
+ if(!name) return;
+
+ let HELPERS_STORAGE = chrome.storage.sync || chrome.storage.local;
+
+ name = '~/cache/' + (name.toLowerCase().replace(/\s+/g, '_'));
+
+ return new Promise((resolve, reject) => {
+ function LOAD(DISK) {
+ let data = JSON.parse(DISK[name] || null);
+
+ return resolve(data);
+ }
+
+ HELPERS_STORAGE.get(null, DISK => {
+ if (chrome.runtime.lastError)
+ chrome.storage.local.get(null, LOAD);
+ else
+ LOAD(DISK);
+ });
+ });
+}
+
+async function save(name = '', data) {
+ if(!name) return;
+
+ let HELPERS_STORAGE = chrome.storage.sync || chrome.storage.local;
+
+ name = '~/cache/' + (name.toLowerCase().replace(/\s+/g, '_'));
+ data = JSON.stringify(data);
+
+ await HELPERS_STORAGE.set({[name]: data}, () => data);
+
+ return name;
+}
+
+async function kill(name) {
+ let HELPERS_STORAGE = chrome.storage.sync || chrome.storage.local;
+
+ return HELPERS_STORAGE.remove(['~/cache/' + (name.toLowerCase().replace(/\s+/g, '_'))]);
+}
+
+async function Notify(state, text, timeout = 7000, requiresClick = true) {
+ return top.postMessage({ type: 'NOTIFICATION', data: { state, text, timeout, requiresClick } }, '*');
+}
+
+async function Require(permission, name, alias, instance) {
+ let allowed = await load(`has/${ name }`),
+ allotted = await load(`get/${ name }`);
+
+ top.postMessage({ type: 'PERMISSION', data: { instance, permission, name, alias, allowed, allotted } });
+
+ /* Already asked for permission */
+ if(typeof allowed == 'boolean')
+ /* The allowed permission(s) */
+ return allotted;
+}
diff --git a/win/history-hack.js b/win/history-hack.js
new file mode 100644
index 0000000..4fb99a3
--- /dev/null
+++ b/win/history-hack.js
@@ -0,0 +1,26 @@
+let __script__ = document.createElement('script');
+
+// Injected DOM script is not a content script anymore;
+// It can modify objects and functions of the page
+__script__.text = `(${
+function() {
+ let history = window.history,
+ __pushState__ = history.pushState,
+ __replaceState__ = history.replaceState;
+
+ history.pushState = function(state, title, url) {
+ __pushState__.call(this, state, title, url);
+
+ window.dispatchEvent(new CustomEvent('pushstate-changed', { detail: state }));
+ };
+
+ history.replaceState = function(state, title, url) {
+ __replaceState__.call(this, state, title, url);
+
+ window.dispatchEvent(new CustomEvent('pushstate-changed', { detail: state }));
+ };
+}
+})();`;
+
+document.head.appendChild(__script__);
+document.head.removeChild(__script__);
diff --git a/win/img/$$$16.png b/win/img/$$$16.png
new file mode 100644
index 0000000..fe328e0
Binary files /dev/null and b/win/img/$$$16.png differ
diff --git a/win/img/$$$32.png b/win/img/$$$32.png
new file mode 100644
index 0000000..1a38cb2
Binary files /dev/null and b/win/img/$$$32.png differ
diff --git a/win/img/$$$48.png b/win/img/$$$48.png
new file mode 100644
index 0000000..76b479a
Binary files /dev/null and b/win/img/$$$48.png differ
diff --git a/win/img/$$16.png b/win/img/$$16.png
new file mode 100644
index 0000000..489099c
Binary files /dev/null and b/win/img/$$16.png differ
diff --git a/win/img/$$32.png b/win/img/$$32.png
new file mode 100644
index 0000000..6bb8609
Binary files /dev/null and b/win/img/$$32.png differ
diff --git a/win/img/$$48.png b/win/img/$$48.png
new file mode 100644
index 0000000..bf82934
Binary files /dev/null and b/win/img/$$48.png differ
diff --git a/win/img/$16.png b/win/img/$16.png
new file mode 100644
index 0000000..d2c1d4d
Binary files /dev/null and b/win/img/$16.png differ
diff --git a/win/img/$32.png b/win/img/$32.png
new file mode 100644
index 0000000..e192dbf
Binary files /dev/null and b/win/img/$32.png differ
diff --git a/win/img/$48.png b/win/img/$48.png
new file mode 100644
index 0000000..be2d54a
Binary files /dev/null and b/win/img/$48.png differ
diff --git a/win/img/128.png b/win/img/128.png
new file mode 100644
index 0000000..4e7ec68
Binary files /dev/null and b/win/img/128.png differ
diff --git a/win/img/16.png b/win/img/16.png
new file mode 100644
index 0000000..e127b7c
Binary files /dev/null and b/win/img/16.png differ
diff --git a/win/img/256.png b/win/img/256.png
new file mode 100644
index 0000000..45fb791
Binary files /dev/null and b/win/img/256.png differ
diff --git a/win/img/32.png b/win/img/32.png
new file mode 100644
index 0000000..e070143
Binary files /dev/null and b/win/img/32.png differ
diff --git a/win/img/48.png b/win/img/48.png
new file mode 100644
index 0000000..749aa93
Binary files /dev/null and b/win/img/48.png differ
diff --git a/win/img/96.png b/win/img/96.png
new file mode 100644
index 0000000..1c7b3c1
Binary files /dev/null and b/win/img/96.png differ
diff --git a/win/img/_16.png b/win/img/_16.png
new file mode 100644
index 0000000..e563efd
Binary files /dev/null and b/win/img/_16.png differ
diff --git a/win/img/_32.png b/win/img/_32.png
new file mode 100644
index 0000000..d5c6c18
Binary files /dev/null and b/win/img/_32.png differ
diff --git a/win/img/_48.png b/win/img/_48.png
new file mode 100644
index 0000000..d84b79f
Binary files /dev/null and b/win/img/_48.png differ
diff --git a/win/img/allocine.png b/win/img/allocine.png
new file mode 100644
index 0000000..c5375d6
Binary files /dev/null and b/win/img/allocine.png differ
diff --git a/win/img/amazon.png b/win/img/amazon.png
new file mode 100644
index 0000000..7f4afc7
Binary files /dev/null and b/win/img/amazon.png differ
diff --git a/win/img/background.png b/win/img/background.png
new file mode 100644
index 0000000..983f25f
Binary files /dev/null and b/win/img/background.png differ
diff --git a/win/img/close.16.png b/win/img/close.16.png
new file mode 100644
index 0000000..70283c6
Binary files /dev/null and b/win/img/close.16.png differ
diff --git a/win/img/close.48.png b/win/img/close.48.png
new file mode 100644
index 0000000..857589e
Binary files /dev/null and b/win/img/close.48.png differ
diff --git a/win/img/couchpotato.png b/win/img/couchpotato.png
new file mode 100644
index 0000000..ed0b243
Binary files /dev/null and b/win/img/couchpotato.png differ
diff --git a/win/img/fandango.png b/win/img/fandango.png
new file mode 100644
index 0000000..20a43db
Binary files /dev/null and b/win/img/fandango.png differ
diff --git a/win/img/flenix.png b/win/img/flenix.png
new file mode 100644
index 0000000..4e70583
Binary files /dev/null and b/win/img/flenix.png differ
diff --git a/win/img/flickmetrix.png b/win/img/flickmetrix.png
new file mode 100644
index 0000000..51885b2
Binary files /dev/null and b/win/img/flickmetrix.png differ
diff --git a/win/img/google.png b/win/img/google.png
new file mode 100644
index 0000000..d10c372
Binary files /dev/null and b/win/img/google.png differ
diff --git a/win/img/gostream.png b/win/img/gostream.png
new file mode 100644
index 0000000..50f830e
Binary files /dev/null and b/win/img/gostream.png differ
diff --git a/win/img/hide.16.png b/win/img/hide.16.png
new file mode 100644
index 0000000..2e17942
Binary files /dev/null and b/win/img/hide.16.png differ
diff --git a/win/img/hide.48.png b/win/img/hide.48.png
new file mode 100644
index 0000000..b7ec4ed
Binary files /dev/null and b/win/img/hide.48.png differ
diff --git a/win/img/hulu.png b/win/img/hulu.png
new file mode 100644
index 0000000..bc5a080
Binary files /dev/null and b/win/img/hulu.png differ
diff --git a/win/img/imdb.png b/win/img/imdb.png
new file mode 100644
index 0000000..d12a985
Binary files /dev/null and b/win/img/imdb.png differ
diff --git a/win/img/itunes.png b/win/img/itunes.png
new file mode 100644
index 0000000..7cbd8cd
Binary files /dev/null and b/win/img/itunes.png differ
diff --git a/win/img/justwatch.png b/win/img/justwatch.png
new file mode 100644
index 0000000..7f4bf1f
Binary files /dev/null and b/win/img/justwatch.png differ
diff --git a/win/img/letterboxd.png b/win/img/letterboxd.png
new file mode 100644
index 0000000..b16eae3
Binary files /dev/null and b/win/img/letterboxd.png differ
diff --git a/win/img/local.couchpotato.png b/win/img/local.couchpotato.png
new file mode 100644
index 0000000..a53d3eb
Binary files /dev/null and b/win/img/local.couchpotato.png differ
diff --git a/win/img/local.medusa.png b/win/img/local.medusa.png
new file mode 100644
index 0000000..36e775d
Binary files /dev/null and b/win/img/local.medusa.png differ
diff --git a/win/img/local.ombi.png b/win/img/local.ombi.png
new file mode 100644
index 0000000..0c9c24d
Binary files /dev/null and b/win/img/local.ombi.png differ
diff --git a/win/img/local.plex.png b/win/img/local.plex.png
new file mode 100644
index 0000000..75ea659
Binary files /dev/null and b/win/img/local.plex.png differ
diff --git a/win/img/local.radarr.png b/win/img/local.radarr.png
new file mode 100644
index 0000000..f55ef6b
Binary files /dev/null and b/win/img/local.radarr.png differ
diff --git a/win/img/local.sickBeard.png b/win/img/local.sickBeard.png
new file mode 100644
index 0000000..bc16cb3
Binary files /dev/null and b/win/img/local.sickBeard.png differ
diff --git a/win/img/local.sonarr.png b/win/img/local.sonarr.png
new file mode 100644
index 0000000..93585db
Binary files /dev/null and b/win/img/local.sonarr.png differ
diff --git a/win/img/local.watcher.png b/win/img/local.watcher.png
new file mode 100644
index 0000000..581d81b
Binary files /dev/null and b/win/img/local.watcher.png differ
diff --git a/win/img/metacritic.png b/win/img/metacritic.png
new file mode 100644
index 0000000..385ca72
Binary files /dev/null and b/win/img/metacritic.png differ
diff --git a/win/img/moviemeter.png b/win/img/moviemeter.png
new file mode 100644
index 0000000..b153f79
Binary files /dev/null and b/win/img/moviemeter.png differ
diff --git a/win/img/movieo.png b/win/img/movieo.png
new file mode 100644
index 0000000..05b13c7
Binary files /dev/null and b/win/img/movieo.png differ
diff --git a/win/img/netflix.png b/win/img/netflix.png
new file mode 100644
index 0000000..e6087a0
Binary files /dev/null and b/win/img/netflix.png differ
diff --git a/win/img/noise.png b/win/img/noise.png
new file mode 100644
index 0000000..7eead13
Binary files /dev/null and b/win/img/noise.png differ
diff --git a/win/img/null.png b/win/img/null.png
new file mode 100644
index 0000000..be90c6b
Binary files /dev/null and b/win/img/null.png differ
diff --git a/win/img/o16.png b/win/img/o16.png
new file mode 100644
index 0000000..2256345
Binary files /dev/null and b/win/img/o16.png differ
diff --git a/win/img/o48.png b/win/img/o48.png
new file mode 100644
index 0000000..010c98c
Binary files /dev/null and b/win/img/o48.png differ
diff --git a/win/img/plex.png b/win/img/plex.png
new file mode 100644
index 0000000..d8c4d9f
Binary files /dev/null and b/win/img/plex.png differ
diff --git a/win/img/plexit.16.png b/win/img/plexit.16.png
new file mode 100644
index 0000000..1661033
Binary files /dev/null and b/win/img/plexit.16.png differ
diff --git a/win/img/plexit.48.png b/win/img/plexit.48.png
new file mode 100644
index 0000000..d7b31ad
Binary files /dev/null and b/win/img/plexit.48.png differ
diff --git a/win/img/radarr.png b/win/img/radarr.png
new file mode 100644
index 0000000..f55ef6b
Binary files /dev/null and b/win/img/radarr.png differ
diff --git a/win/img/reload.16.png b/win/img/reload.16.png
new file mode 100644
index 0000000..f81b627
Binary files /dev/null and b/win/img/reload.16.png differ
diff --git a/win/img/reload.48.png b/win/img/reload.48.png
new file mode 100644
index 0000000..5c61d2b
Binary files /dev/null and b/win/img/reload.48.png differ
diff --git a/win/img/rottentomatoes.png b/win/img/rottentomatoes.png
new file mode 100644
index 0000000..17b5f2b
Binary files /dev/null and b/win/img/rottentomatoes.png differ
diff --git a/win/img/settings.16.png b/win/img/settings.16.png
new file mode 100644
index 0000000..edba003
Binary files /dev/null and b/win/img/settings.16.png differ
diff --git a/win/img/settings.48.png b/win/img/settings.48.png
new file mode 100644
index 0000000..082253b
Binary files /dev/null and b/win/img/settings.48.png differ
diff --git a/win/img/shanaproject.png b/win/img/shanaproject.png
new file mode 100644
index 0000000..d56fb84
Binary files /dev/null and b/win/img/shanaproject.png differ
diff --git a/win/img/show.16.png b/win/img/show.16.png
new file mode 100644
index 0000000..47be280
Binary files /dev/null and b/win/img/show.16.png differ
diff --git a/win/img/show.48.png b/win/img/show.48.png
new file mode 100644
index 0000000..353409a
Binary files /dev/null and b/win/img/show.48.png differ
diff --git a/win/img/showrss.png b/win/img/showrss.png
new file mode 100644
index 0000000..f317e88
Binary files /dev/null and b/win/img/showrss.png differ
diff --git a/win/img/sonarr.png b/win/img/sonarr.png
new file mode 100644
index 0000000..93585db
Binary files /dev/null and b/win/img/sonarr.png differ
diff --git a/win/img/store-logo.png b/win/img/store-logo.png
new file mode 100644
index 0000000..11dee12
Binary files /dev/null and b/win/img/store-logo.png differ
diff --git a/win/img/tmdb.png b/win/img/tmdb.png
new file mode 100644
index 0000000..b2a0165
Binary files /dev/null and b/win/img/tmdb.png differ
diff --git a/win/img/trakt.png b/win/img/trakt.png
new file mode 100644
index 0000000..cfc4316
Binary files /dev/null and b/win/img/trakt.png differ
diff --git a/win/img/tubi.png b/win/img/tubi.png
new file mode 100644
index 0000000..5a8ce56
Binary files /dev/null and b/win/img/tubi.png differ
diff --git a/win/img/tv-maze.png b/win/img/tv-maze.png
new file mode 100644
index 0000000..23858e1
Binary files /dev/null and b/win/img/tv-maze.png differ
diff --git a/win/img/tvdb.png b/win/img/tvdb.png
new file mode 100644
index 0000000..2dd3cf9
Binary files /dev/null and b/win/img/tvdb.png differ
diff --git a/win/img/verizon.png b/win/img/verizon.png
new file mode 100644
index 0000000..7ed2596
Binary files /dev/null and b/win/img/verizon.png differ
diff --git a/win/img/vrv.png b/win/img/vrv.png
new file mode 100644
index 0000000..5e82ab4
Binary files /dev/null and b/win/img/vrv.png differ
diff --git a/win/img/vudu.png b/win/img/vudu.png
new file mode 100644
index 0000000..c68017e
Binary files /dev/null and b/win/img/vudu.png differ
diff --git a/win/img/vumoo.png b/win/img/vumoo.png
new file mode 100644
index 0000000..ddf273a
Binary files /dev/null and b/win/img/vumoo.png differ
diff --git a/win/img/watcher.png b/win/img/watcher.png
new file mode 100644
index 0000000..581d81b
Binary files /dev/null and b/win/img/watcher.png differ
diff --git a/win/img/youtube.png b/win/img/youtube.png
new file mode 100644
index 0000000..2758467
Binary files /dev/null and b/win/img/youtube.png differ
diff --git a/win/manifest.json b/win/manifest.json
new file mode 100644
index 0000000..a47ec8e
--- /dev/null
+++ b/win/manifest.json
@@ -0,0 +1,225 @@
+{
+ "name": "Web to Plex",
+ "description": "Adds a button on various movie & TV show sites to open the item in Plex, or send to your designated NZB manager for download.",
+ "homepage_url": "https://github.com/SpaceK33z/web-to-plex/",
+
+ "manifest_version": 2,
+ "version": "4.1.1.8",
+
+ "icons": {
+ "16": "img/16.png",
+ "32": "img/32.png",
+ "48": "img/48.png",
+ "96": "img/96.png",
+ "128": "img/128.png",
+ "256": "img/256.png"
+ },
+
+ "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
+
+ "content_scripts": [
+ // Allows media downloads
+ {
+ "matches": [
+ "*://*.openload.co/*", "*://*.oload.co/*",
+ "*://*.openload.com/*", "*://*.oload.com/*",
+ "*://*.openload.fun/*", "*://*.oload.fun/*",
+ "*://*.openload.biz/*", "*://*.oload.biz/*",
+ "*://*.openload.vip/*", "*://*.oload.vip/*",
+ "*://*.openload.club/*", "*://*.oload.club/*",
+ "*://*.openload.io/*", "*://*.oload.io/*",
+ "*://*.openload.xyz/*", "*://*.oload.xyz/*",
+ "*://*.openload.cc/*", "*://*.oload.cc/*",
+ "*://*.openload.to/*", "*://*.oload.to/*",
+ "*://*.openload.is/*", "*://*.oload.is/*",
+ "*://*.openload.gg/*", "*://*.oload.gg/*",
+ "*://*.openload.tv/*", "*://*.oload.tv/*",
+ "*://*.openload.fm/*", "*://*.oload.fm/*",
+ "*://*.openload.cx/*", "*://*.oload.cx/*",
+ "*://*.openload.ac/*", "*://*.oload.ac/*",
+ "*://*.openload.name/*", "*://*.oload.name/*",
+ "*://*.openload.global/*", "*://*.oload.global/*"
+ ],
+ "js": ["download/oload.js"],
+ "all_frames": true
+ },
+ {
+ "matches": ["*://*.consistent.stream/titles/*", "*://*.consistent.stream/watch/*"],
+ "js": ["download/consistent.js"],
+ "all_frames": true
+ },
+ {
+ "matches": ["*://app.plex.tv/desktop#!/server/*/details?*"],
+ "js": ["download/plex.js"],
+ "all_frames": true
+ },
+ {
+ "matches": ["*://*.gounlimited.to/embed-*"],
+ "js": ["download/gounlimited.js"],
+ "all_frames": true
+ },
+ {
+ "matches": ["*://*.fembed.com/v/*"],
+ "js": ["download/fembed.js"],
+ "all_frames": true
+ },
+
+ // The sites
+ {
+ "matches": ["*://*.movieo.me/*"],
+ "js": ["history-hack.js", "utils.js", "sites/movieo/index.js"],
+ "css": ["sites/movieo/index.css"]
+ },{
+ "matches": ["*://*.imdb.com/*"],
+ "js": ["utils.js", "sites/imdb/index.js"],
+ "css": ["sites/imdb/index.css"]
+ },{
+ "matches": ["*://*.trakt.tv/*"],
+ "js": ["history-hack.js", "utils.js", "sites/trakt/index.js"],
+ "css": ["sites/trakt/index.css"]
+ },{
+ "matches": ["*://*.letterboxd.com/*"],
+ "js": ["utils.js", "sites/letterboxd/index.js"],
+ "css": ["sites/letterboxd/index.css"]
+ },{
+ "matches": ["*://*.tvmaze.com/shows/*"],
+ "js": ["utils.js", "sites/tvmaze/index.js"],
+ "css": ["sites/tvmaze/index.css"]
+ },{
+ "matches": ["*://*.thetvdb.com/series/*"],
+ "js": ["utils.js", "sites/tvdb/index.js"],
+ "css": ["sites/tvdb/index.css"]
+ },{
+ "matches": ["*://*.themoviedb.org/movie/*", "*://*.themoviedb.org/tv/*"],
+ "js": ["utils.js", "sites/tmdb/index.js"],
+ "css": ["sites/tmdb/index.css"]
+ },{
+ "matches": ["*://*.vrv.co/*"],
+ "js": ["utils.js", "sites/vrv/index.js"],
+ "css": ["sites/vrv/index.css"]
+ },{
+ "matches": ["*://*.hulu.com/*"],
+ "js": ["utils.js", "sites/hulu/index.js"],
+ "css": ["sites/hulu/index.css"]
+ },{
+ "matches": ["*://play.google.com/store/*"],
+ "js": ["utils.js", "sites/google/play.js"],
+ "css": ["sites/google/index.css"]
+ },{
+ "matches": ["*://itunes.apple.com/*"],
+ "js": ["utils.js", "sites/itunes/index.js"],
+ "css": ["sites/itunes/index.css"]
+ },{
+ "matches": ["*://*.metacritic.com/*"],
+ "js": ["utils.js", "sites/metacritic/index.js"],
+ "css": ["sites/metacritic/index.css"]
+ },{
+ "matches": ["*://*.fandango.com/*"],
+ "js": ["utils.js", "sites/fandango/index.js"],
+ "css": ["sites/fandango/index.css"]
+ },{
+ "matches": ["*://*.amazon.com/*"],
+ "js": ["utils.js", "sites/amazon/index.js"],
+ "css": ["sites/amazon/index.css"]
+ },{
+ "matches": ["*://*.vudu.com/*"],
+ "js": ["utils.js", "sites/vudu/index.js"],
+ "css": ["sites/vudu/index.css"]
+ },{
+ "matches": ["*://*.verizon.com/*"],
+ "js": ["utils.js", "sites/verizon/index.js"],
+ "css": ["sites/verizon/index.css"]
+ },{
+ "matches": ["*://*.couchpotato.life/*/*"],
+ "js": ["utils.js", "sites/couchpotato/index.js"],
+ "css": ["sites/couchpotato/index.css"]
+ },{
+ "matches": ["*://*.rottentomatoes.com/*/*"],
+ "js": ["utils.js", "sites/rottentomatoes/index.js"],
+ "css": ["sites/rottentomatoes/index.css"]
+ },{
+ "matches": ["*://*.netflix.com/watch/*"],
+ "js": ["utils.js", "sites/netflix/index.js"],
+ "css": ["sites/netflix/index.css"]
+ },{
+ "matches": ["*://*.vumoo.to/*"],
+ "js": ["utils.js", "sites/vumoo/index.js"],
+ "css": ["sites/vumoo/index.css"]
+ },{
+ "matches": ["*://www.google.com/*"],
+ "js": ["utils.js", "sites/google/index.js"],
+ "css": ["sites/google/index.css"]
+ },{
+ "matches": ["*://www.youtube.com/*"],
+ "js": ["utils.js", "sites/youtube/index.js"],
+ "css": ["sites/youtube/index.css"]
+ },{
+ "matches": ["*://*.flickmetrix.com/*"],
+ "js": ["utils.js", "sites/flickmetrix/index.js"],
+ "css": ["sites/flickmetrix/index.css"]
+ },{
+ "matches": ["*://*.justwatch.com/*"],
+ "js": ["utils.js", "sites/justwatch/index.js"],
+ "css": ["sites/justwatch/index.css"]
+ },{
+ "matches": ["*://*.moviemeter.nl/*"],
+ "js": ["utils.js", "sites/moviemeter/index.js"],
+ "css": ["sites/moviemeter/index.css"]
+ },{
+ "matches": ["*://*.allocine.fr/*"],
+ "js": ["utils.js", "sites/allocine/index.js"],
+ "css": ["sites/allocine/index.css"]
+ },{
+ "matches": ["*://*.gostream.site/*"],
+ "js": ["utils.js", "sites/gostream/index.js"],
+ "css": ["sites/gostream/index.css"]
+ },{
+ "matches": ["*://*.tubitv.com/*"],
+ "js": ["utils.js", "sites/tubi/index.js"],
+ "css": ["sites/tubi/index.css"]
+ },{
+ "matches": ["*://ephellon.github.io/web.to.plex/?*", "*://ephellon.github.io/web.to.plex/index.html?*", "*://ephellon.github.io/web.to.plex/login.html?*"],
+ "js": ["utils.js", "sites/webtoplex/index.js"],
+ "css": ["sites/webtoplex/index.css"]
+ },{
+ "matches": ["*://app.plex.tv/desktop/*"],
+ "js": ["utils.js", "sites/plex/index.js"],
+ "css": ["sites/plex/index.css"]
+ },{
+ "matches": ["*://*/*"],
+ "js": ["utils.js", "sites/common.js"],
+ "css": ["sites/common.css", "sites/theme.css"]
+ }
+ ],
+
+ "background": {
+ "scripts": ["background.js", "plugn.js"],
+ "persistent": true
+ },
+
+ "options_page": "options/index.html",
+ "options_ui": {
+ "page": "options/index.html",
+ "open_in_tab": true
+ },
+
+ "browser_action": {
+ "default_icon": {
+ "16": "img/16.png",
+ "32": "img/32.png",
+ "48": "img/48.png",
+ "96": "img/96.png"
+ },
+ "default_title": "Web to Plex",
+ "default_popup": "popup/index.html"
+ },
+
+ "permissions": [
+ "tabs",
+ "storage",
+ "downloads",
+ "contextMenus",
+ ""
+ ],
+ "web_accessible_resources": ["img/*", "options/*"]
+}
diff --git a/win/options/compare.js b/win/options/compare.js
new file mode 100644
index 0000000..9cb6275
--- /dev/null
+++ b/win/options/compare.js
@@ -0,0 +1,38 @@
+/** GitHub@alexey-bass
+ * Simply compares two string version values.
+ *
+ * Example:
+ * compareVer('1.1', '1.2') => -1
+ * compareVer('1.1', '1.1') => 0
+ * compareVer('1.2', '1.1') => 1
+ * compareVer('2.23.3', '2.22.3') => 1
+ *
+ * Returns:
+ * -1 = left is LOWER = right is GREATER
+ * 0 = they are equal
+ * 1 = left is GREATER = right is LOWER
+ * And FALSE if one of input versions are not valid
+ *
+ * @function
+ * @param {String} left Version #1
+ * @param {String} right Version #2
+ * @return {Integer|Boolean}
+ * @author Alexey Bass (albass)
+ * @since 2011-07-14
+ */
+function compareVer(left, right) {
+ if(typeof left + typeof right != 'stringstring')
+ return false;
+
+ for(let a = left.split('.'), b = right.split('.'), i = 0, l = Math.max(a.length, b.length); i < l; i++) {
+ if((a[i] && !b[i] && parseInt(a[i]) > 0) || (parseInt(a[i]) > parseInt(b[i])))
+ return +1
+ /* left is higher */;
+ else if((b[i] && !a[i] && parseInt(b[i]) > 0) || (parseInt(a[i]) < parseInt(b[i])))
+ return -1
+ /* right is higher */;
+ }
+
+ return 0
+ /* equal */;
+}
diff --git a/win/options/index.css b/win/options/index.css
new file mode 100644
index 0000000..f264ee0
--- /dev/null
+++ b/win/options/index.css
@@ -0,0 +1,900 @@
+* {
+ outline: #0000 !important;
+}
+
+html, body {
+ height: 100%;
+}
+
+body {
+ background: url(../img/noise.png) fixed, url(../img/background.png) no-repeat fixed center/cover, #3f4245 !important;
+ color: #999 !important;
+ font-family: Open Sans Regular, Helvetica Neue, Helvetica, Arial, sans-serif, system;
+ flex-grow: 1;
+ overflow-x: hidden;
+ overflow-y: auto;
+
+ padding: 0;
+ position: absolute;
+ margin: 0;
+
+ height: 100%;
+ width: 100%;
+}
+
+display {
+ display: block;
+ font-size: 18px;
+ overflow-y: auto;
+
+ padding: 50px 50px 0 50px;
+ position: fixed;
+ top: 0;
+ left: 20%;
+
+ height: 90%;
+ width: calc(80% - 100px);
+}
+
+display:empty {
+ background: url(../img/256.png) no-repeat fixed 60%;
+ /* todo: loading animation */
+}
+
+display[loading="true"i] * {
+ animation: fade 0.3s 1 ease-in-out;
+ opacity: 0.3;
+}
+
+display[loading="true"i]:after {
+ border: 2px solid #e5a00d;
+ border-left: 2px solid #0000;
+ border-radius: 50%;
+ color: #0000;
+ content: '----';
+ font-size: 8px;
+
+ animation: spin 1.1s infinite linear;
+
+ position: fixed;
+ margin: 0 auto 10px;
+ left: calc(50% + 24px);
+ top: calc(50% + 24px);
+
+ height: 12px;
+ width: 12px;
+}
+
+[_title_]:before {
+ color: #fff !important;
+ content: attr(_title_) "\00a0\2014\00a0" attr(_sub-title_);
+ border: 0;
+ border-bottom: 1px solid #6668;
+ display: block;
+ font-size: 1.5em;
+
+ width: 100%;
+}
+
+a {
+ color: #cc7b19 !important;
+ text-decoration: none !important;
+}
+
+[target="_blank"]:not(#version)::after {
+ content: " [\2197]";
+ font-size: 70%;
+
+ vertical-align: super;
+}
+
+hr {
+ border: 0;
+ border-top: 1px solid #6668;
+
+ margin: 6px 0;
+}
+
+hr:last-child {
+ display: none;
+}
+
+article {
+ z-index: 3;
+}
+
+.h1 {
+ font-size: 300%;
+}
+
+.h2 {
+ font-size: 200%;
+}
+
+.h3 {
+ font-size: 150%;
+}
+
+.h4 {
+ font-size: 100%;
+}
+
+.h5 {
+ font-size: 75%;
+}
+
+.h6 {
+ font-size: 50%;
+}
+
+section {
+ display: block !important;
+ margin-bottom: 20px;
+ box-sizing: border-box;
+}
+
+label {
+ color: #eee !important;
+ font-weight: 400 !important;
+ display: inline-block;
+ margin-bottom: 5px;
+}
+
+input[type="text"], input[type="password"], textarea, select {
+ width: 30vw !important;
+ line-height: 1.5em !important;
+ transition: background 0.2s;
+ display: block !important;
+ height: 38px !important;
+ padding: 6px 12px;
+ font-size: 16px !important;
+ color: #eee !important;
+ vertical-align: middle;
+ background: rgba(255, 255, 255, 0.25);
+ border: 3px solid rgba(0, 0, 0, 0);
+ border-radius: 3px;
+ font-family: inherit;
+ margin: 0;
+}
+
+textarea, select[multiple] {
+ height: 114px !important;
+}
+
+div:not(body > div) {
+ color: rgba(255, 255, 255, 0.45) !important;
+ display: block;
+ margin-top: 5px !important;
+ margin-bottom: 10px !important;
+ box-sizing: border-box;
+ font-size: 14px !important;
+ z-index: 18 !important;
+}
+
+button, input[type="button"i], .button {
+ padding: 10px 18px !important;
+ font-size: 16px !important;
+ line-height: 1.33 !important;
+ border-radius: 3px;
+ font-family: inherit;
+ text-transform: uppercase;
+ border: 0;
+ box-shadow: none !important;
+ position: relative;
+ overflow: hidden;
+ color: #fff !important;
+ background: #cc7b19 !important;
+ margin-bottom: 0;
+ font-weight: 400 !important;
+ vertical-align: middle;
+ cursor: pointer !important;
+ white-space: nowrap;
+ user-select: none;
+ transition: all 0.1s;
+}
+
+button:hover, input[type="button"i]:hover, .button:hover {
+ background: #e59029 !important;
+}
+
+[id$="_test"] {
+ background: #cc7b19 !important;
+ margin-bottom: 2px;
+ padding: 10px 8px 10px 10px !important;
+}
+
+[id$="_status"] {
+ padding: 0 6px !important;
+ font-size: 16px !important;
+ border-radius: 3px;
+ font-family: monospace, sans-serif, sans, arial;
+ border: 0;
+ box-shadow: none !important;
+ color: #fff !important;
+ background: #666 !important;
+ border-radius: 4px;
+}
+
+[id$="_status"].false {
+ background: #cc1b19 !important;
+}
+
+[id$="_status"].true {
+ background: #7bcc19 !important;
+}
+
+[id$="token"], [data-option$="Token"], [data-option$="API"] {
+ font-family: monospace, consolas, sans-serif, sans serif, sans, arial;
+}
+
+[id$="-token"i]:not(:placeholder-shown), [data-option$="-API"i]:not(:placeholder-shown) {
+ text-transform: uppercase;
+}
+
+em {
+ color: #cc7b19 !important;
+}
+
+strike, st, k {
+ text-decoration: line-through !important;
+}
+
+[href="#!/NaCl+Iw"], [href="#!/NaCl+Iw"] * {
+ cursor: not-allowed !important;
+ opacity: 0.5 !important;
+}
+
+select {
+ margin-left: 10px !important;
+ font-size: 16px !important;
+ line-height: inherit;
+ text-transform: none;
+}
+
+#sidebar {
+ background-color: rgba(0,0,0,.15);
+ display: flex;
+ flex: 0 0 240px;
+ flex-direction: column;
+ transform: translateZ(0);
+ overflow-y: auto;
+
+ height: 100%;
+ width: 20%;
+}
+
+header {
+ align-content: center;
+ color: hsla(0,0%,100%,.3);
+ font-family: Open Sans Bold,Helvetica Neue,Helvetica,Arial,sans-serif;
+ font-size: 14px;
+ line-height: 45px;
+ overflow: hidden !important;
+ text-overflow: ellipsis;
+ text-transform: uppercase;
+ white-space: nowrap !important;
+
+ padding-left: 25px;
+
+ height: 45px;
+ min-width: 0;
+ max-width: 100%;
+
+}
+
+article {
+ color: #999;
+
+ position: relative !important;
+ padding: 5px !important;
+ padding-left: 50px !important;
+}
+
+summary, option {
+ color: #999;
+ cursor: pointer;
+
+ margin-bottom: 12px !important;
+ margin-top: 0 !important;
+ padding-left: 0;
+}
+
+[x-mode]:after, [beta]:after {
+ content: ' (Experimental Feature)';
+ color: #fff;
+}
+
+#sidebar summary {
+ font-size: 12px;
+
+ margin-left: -50px;
+ padding-left: 50px;
+}
+
+#sidebar summary:hover {
+ background: #ffffff14;
+ border: 0 solid #0000;
+ border-top-right-radius: 4px;
+ border-bottom-right-radius: 4px;
+}
+
+#sidebar details[open] summary {
+ color: #cc7b19;
+}
+
+#sidebar .checkbox {
+ display: block;
+ transform: scale(0.75);
+}
+
+display > summary:first-child {
+ font-size: 1.5em;
+}
+
+display summary > .checkbox {
+ display: block;
+}
+
+select:not([multiple]) > option {
+ background: url(../img/noise.png), #3f4245 !important;
+}
+
+details:last-child > summary {
+ margin-bottom: 0 !important;
+}
+
+.bar > article > details > summary {
+ display: list-item;
+ list-style-type: none;
+}
+
+.bar > article > details[open] > *:not(summary) {
+ display: none !important;
+}
+
+.bar > article > details > summary::-webkit-details-marker {
+ color: #0000;
+}
+
+.bar > article > details[open] > summary::before {
+ content: '\2022';
+ color: inherit !important;
+ font-size: 16px;
+
+ margin-left: 2px;
+ margin-top: -2px;
+ position: absolute;
+}
+
+#save {
+ margin: 6px 12px;
+}
+
+footer {
+ bottom: 0;
+ position: fixed;
+
+ width: 100%;
+}
+
+[using], [counter-for] {
+ color: #197bcc;
+}
+
+[using]::after {
+ content: '\2610';
+}
+
+[in-use="true"i]::after {
+ content: '\2611';
+}
+
+[special][using]:not([in-use="true"i]) {
+ color: #666;
+}
+
+[special][in-use="true"i], [special][counter-for] {
+ color: #cc7b19;
+}
+
+[counter-for] {
+ border: 1px solid #197bcc;
+
+ margin: 0;
+ padding: 0 3px;
+}
+
+[counter-for][in-use]::after {
+ content: '' !important;
+}
+
+[special][counter-for] {
+ border-color: #cc7b19;
+}
+
+#sidebar [counter-for] {
+ font-size: 10px;
+}
+
+[top] {
+ border-bottom-left-radius: 0 !important;
+ border-bottom-right-radius: 0 !important;
+}
+
+[bottom] {
+ border-top-left-radius: 0 !important;
+ border-top-right-radius: 0 !important;
+}
+
+[type="password"] ~ .hidden-help, .hide {
+ display: none;
+}
+
+.test {
+ background: #197bcc !important;
+ font-family: monospace;
+}
+
+.test:hover {
+ background: #298bdc !important;
+}
+
+#sidebar .checkbox:not([special]) {
+ display: none !important;
+}
+
+display summary [using]:not([special]) {
+ display: none !important;
+}
+
+/* bbodine @CodePen - https://codepen.io/bbodine1/pen/novBm */
+.checkbox {
+ width: 80px;
+ height: 26px;
+ background: #000;
+ margin: 15px 0;
+ position: relative;
+ border-radius: 50px;
+ box-shadow: inset 0px 1px 1px rgba(0, 0, 0, 0.5), 0px 1px 0px rgba(255, 255, 255, 0.2);
+}
+
+span.checkbox {
+ display: inline-block;
+
+ margin: 0;
+ vertical-align: text-bottom;
+}
+
+.checkbox::after {
+ content: 'OFF';
+ color: #666;
+ position: absolute;
+ right: 10px;
+ z-index: 0;
+ font: 12px/26px Arial, sans-serif;
+ font-weight: bold;
+ text-shadow: 1px 1px 0px rgba(255, 255, 255, 0.15);
+}
+
+.checkbox::before {
+ content: 'ON';
+ color: #cc7b19;
+ position: absolute;
+ left: 10px;
+ z-index: 0;
+ font: 12px/26px Arial, sans-serif;
+ font-weight: bold;
+}
+
+.checkbox[prompt-yes]::before {
+ content: attr(prompt-yes);
+ text-transform: uppercase;
+}
+
+.checkbox[prompt-no]::after {
+ content: attr(prompt-no);
+ text-transform: uppercase;
+}
+
+.checkbox[prompt-size="large"i]::before, .checkbox[prompt-size="large"i]::after {
+ font-size: 30px !important;
+}
+
+.checkbox[prompt-size="medium"i]::before, .checkbox[prompt-size="medium"i]::after {
+ font-size: 21px !important;
+}
+
+.checkbox[prompt-size="normal"i]::before, .checkbox[prompt-size="normal"i]::after {
+ font-size: 12px !important;
+}
+
+.checkbox[prompt-size="small"i]::before, .checkbox[prompt-size="small"i]::after {
+ font-size: 6px !important;
+}
+
+.checkbox[prompt="y/n"i]::before {
+ content: 'YES';
+}
+
+.checkbox[prompt="y/n"i]::after {
+ content: 'NO';
+}
+
+.checkbox label {
+ display: block;
+ width: 34px;
+ height: 20px;
+ cursor: pointer;
+ position: absolute;
+ top: 3px;
+ left: 3px;
+ z-index: 1;
+ background: #666;
+ border-radius: 50px;
+ transition: all 0.4s ease;
+ box-shadow: 0px 2px 5px 0px rgba(0, 0, 0, 0.3);
+}
+
+.checkbox input[type=checkbox] {
+ visibility: hidden;
+}
+
+.checkbox input[type=checkbox]:checked + label {
+ left: 43px;
+ background: #cc7b19;
+}
+
+.checkbox[disabled] {
+ opacity: 0.25 !important;
+}
+
+.checkbox[disabled] + [using] {
+ color: #666;
+ opacity: 0.25;
+}
+
+[white], .checkbox[white]::before {
+ color: #fff !important;
+}
+.checkbox[white] input:checked + label {
+ background: #fff !important;
+}
+
+[orange], .checkbox[orange]::before {
+ color: #cc7b19 !important;
+}
+.checkbox[orange] input:checked + label {
+ background: #cc7b19 !important;
+}
+
+[blue], .checkbox[blue]::before {
+ color: #197bcc !important;
+}
+.checkbox[blue] input:checked + label {
+ background: #197bcc !important;
+}
+
+input[type="range"] {
+ appearance: none;
+ -webkit-appearance: none;
+
+ background: #0004;
+ outline: none;
+
+ height: 5px !important;
+ width: 83% !important;
+}
+
+input[type="range"] + output {
+ display: inline-block;
+ position: relative;
+ width: 7% !important;
+ color: #cc7b19;
+ line-height: 20px;
+ text-align: center;
+ border-radius: 3px;
+ background: #000;
+ padding: 5px 10px;
+ margin-left: 8px;
+ vertical-align: sub;
+}
+
+input[type="range"] + output::after {
+ position: absolute;
+ top: 8px;
+ left: -7px;
+ width: 0;
+ height: 0;
+ border-top: 7px solid transparent;
+ border-right: 7px solid #000;
+ border-bottom: 7px solid transparent;
+ content: '';
+}
+
+input[type="range"]::-webkit-slider-thumb {
+ appearance: none;
+ -webkit-appearance: none;
+
+ background: #cc7b19;
+ border: 1px solid #cc7b19;
+ border-radius: 100%;
+ cursor: pointer;
+
+ height: 32px;
+ width: 32px;
+}
+
+[disabled], [disabled] * {
+ cursor: not-allowed !important;
+ color: #909090EE !important;
+}
+
+[code], code {
+ background: #222 !important;
+ border-radius: 3px !important;
+ box-shadow: none !important;
+ box-sizing: border-box !important;
+ font-family: "System, Monospace, Menlo, Arial", Consolas, "Liberation Mono", Menlo, Courier, monospace !important;
+ font-size: 12px !important;
+ line-height: 18px !important;
+
+ margin: 0 !important;
+ padding: 0.2em 0.4em !important;
+}
+
+#version {
+ background: #0000 !important;
+ border: 1px solid #666 !important;
+ color: #666 !important;
+
+ position: fixed !important;
+ right: 12px !important;
+ top: calc(100vh - 30px) !important;
+
+ transition: border 0.15s, color 0.15s;
+}
+
+/* Release is higher than GitHub */
+#version[status="high"] {
+ border-color: #6cc644 !important;
+ color: #6cc644 !important;
+}
+
+/* Release is same as GitHub */
+#version[status="same"] {
+ border-color: #197bcc !important;
+ color: #197bcc !important;
+}
+
+/* Release is lower than GitHub */
+#version[status="low"] {
+ border-color: #f66a0a !important;
+ color: #f3582c !important;
+}
+
+/* notifications */
+.notification {
+ background: #F45A26;
+ border-radius: 4px;
+ color: #FFF !important;
+ cursor: pointer;
+ display: block;
+ font-family: arial, verdana, sans-serif;
+ font-size: 20px;
+ text-align: center;
+
+ position: fixed;
+ left: 50%;
+ margin-left: -175px;
+ padding: 10px;
+ top: 80px;
+
+ width: 350px;
+ z-index: 999999;
+}
+
+/* Web to Plex general information notifications */
+.notification.info {
+ background: #666 !important;
+}
+
+/* Web to Plex update notifications */
+.notification.update {
+ background: #2A2AFF !important;
+}
+
+/* Web to Plex warning notifications */
+.notification.warning {
+ background: #FF2A2A !important;
+}
+
+/* Web to Plex prompt */
+.prompt {
+ background: #0008 !important;
+ box-sizing: border-box !important;
+ color: #eee !important;
+ display: block !important;
+ font-family: Open Sans Regular, Helvetica Neue, Helvetica, Arial, sans-serif;
+ font-size: 14px;
+ line-height: 24px;
+ overflow: auto;
+
+ height: 100% !important;
+ width: 100% !important;
+
+ bottom: 0 !important;
+ left: 0 !important;
+ right: 0 !important;
+ top: 0 !important;
+ position: fixed !important;
+ z-index: 999999999 !important;
+}
+
+.prompt-body {
+ background: #282828;
+ box-shadow: 0 5px 15px #0008;
+ display: block;
+
+ left: 20%;
+ top: 5%;
+ padding-top: 10px;
+ padding-bottom: 70px;
+ position: relative;
+
+ height: 60%;
+ width: 60%;
+}
+
+.prompt-header, .prompt-footer {
+ background: #323232;
+ border: 1px solid #0000;
+ box-sizing: border-box;
+ color: #eee;
+ text-size-adjust: 100%;
+
+ margin-top: 0;
+ padding: 15px 20px;
+ position: absolute;
+
+ height: 65px;
+ width: 100%;
+
+ -webkit-tap-highlight-color: #0000;
+}
+
+.prompt-header {
+ text-align: left;
+ border-bottom-color: #222;
+ border-bottom-width: 1px;
+ border-top-left-radius: 3px;
+ border-top-right-radius: 3px;
+
+ top: 0;
+}
+
+.prompt-options {
+ display: block;
+ overflow-x: hidden;
+ overflow-y: auto;
+
+ padding: 12px;
+ position: relative;
+ top: 65px;
+
+ max-height: calc(100% - 65px);
+}
+
+.prompt-option {
+ background: #323232;
+ border: 1px solid #202020;
+ border-radius: 3px;
+ color: #999;
+ display: block;
+ text-align: left;
+
+ margin-bottom: 10px;
+ padding: 10px;
+
+ min-height: 20px;
+}
+
+.prompt-option.mutable {
+ max-width: 60%;
+}
+
+.prompt-option.mutable > *:last-child {
+ background: #ffffff40;
+ border-radius: 3px;
+ transition: all 0.1s;
+
+ height: 30px;
+ width: 30px;
+
+ float: right;
+ margin-right: -9px;
+ margin-top: -9px;
+ padding: 0;
+}
+
+.prompt-option.mutable > *:last-child:hover {
+ background: #ffffff4d;
+}
+
+.prompt-option.mutable > *:last-child::after {
+ content: '\00d7';
+}
+
+.prompt-footer {
+ text-align: right;
+ border-bottom-left-radius: 3px;
+ border-bottom-right-radius: 3px;
+ border-top-color: #222;
+ border-top-width: 1px;
+
+ bottom: 0;
+}
+
+.prompt-input {
+ float: left;
+ position: relative;
+ margin-left: -16px !important;
+ margin-top: -11px !important;
+}
+
+.prompt-accept, .prompt-decline {
+ transition: all 0.1s;
+}
+
+.prompt-accept {
+ background: #cc7b19 !important;
+ margin-left: 5px !important;
+}
+
+.prompt-accept:hover {
+ background: #e59029 !important;
+}
+
+.prompt-decline {
+ background: #ffffff40 !important;
+}
+
+.prompt-decline:hover {
+ background: #ffffff4d !important;
+}
+
+
+*::-webkit-scrollbar {
+ width: 10px;
+}
+
+*::-webkit-scrollbar-thumb {
+ min-height: 50px;
+ background: rgba(255, 255, 255, 0.15);
+ border: 2px solid rgba(0, 0, 0, 0);
+ border-radius: 8px;
+ background-clip: padding-box;
+}
+
+*::-webkit-scrollbar-track {
+ /* background: url(../img/noise.png) fixed, #3f4245 !important; */
+}
+
+*::placeholder {
+ color: #999 !important;
+}
+
+*::-webkit-input-placeholder {
+ color: #999;
+}
+
+@keyframes spin {
+ 0% { transform: rotate(0deg) }
+
+ 100% { transform: rotate(360deg) }
+}
+
+@keyframes fade {
+ 0% { opacity: 1 }
+
+ 100% { opacity: 0.3 }
+}
diff --git a/win/options/index.html b/win/options/index.html
new file mode 100644
index 0000000..621e084
--- /dev/null
+++ b/win/options/index.html
@@ -0,0 +1,989 @@
+
+
+
+
+
+ Web To Plex | Options
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ...
+
+
+
+
+
+
+
+
+
diff --git a/win/options/index.js b/win/options/index.js
new file mode 100644
index 0000000..76725f3
--- /dev/null
+++ b/win/options/index.js
@@ -0,0 +1,2778 @@
+/* global parseXML */
+/* Notes:
+ #1: See https://github.com/SpaceK33z/web-to-plex/commit/db01d1a83d32e4d73f2ea671f634e6cc5b4c0fe7
+ #2: See https://github.com/SpaceK33z/web-to-plex/commit/27506b9a4c12496bd7aad6ee09deb8a5b9418cac
+ #3: See https://github.com/SpaceK33z/web-to-plex/issues/21
+ #4: See https://github.com/SpaceK33z/web-to-plex/issues/61
+*/
+
+let DEVELOPER_MODE;
+
+if(chrome.runtime.lastError)
+ /* Always causes errors on *nix machines, so just "poke" the errors here */
+ chrome.runtime.lastError.message;
+
+// FireFox doesn't support sync storage.
+const storage = (chrome.storage.sync || chrome.storage.local),
+ $ = top.$ = (selector, all) => (all? [...document.querySelectorAll(selector)]: document.querySelector(selector)),
+ $$ = top.$$ = (selector, all) => (all? [...$('display').querySelectorAll(selector)]: $('display').querySelector(selector)),
+ __servers__ = $('[data-option="preferredServer"]'),
+ __sickBeard_qualityProfile__ = $(`[data-option="sickBeardQualityProfileId"]`),
+ __sickBeard_storagePath__ = $(`[data-option="sickBeardStoragePath"]`),
+ __medusa_qualityProfile__ = $(`[data-option="medusaQualityProfileId"]`),
+ __medusa_storagePath__ = $(`[data-option="medusaStoragePath"]`),
+ __watcher_qualityProfile__ = $(`[data-option="watcherQualityProfileId"]`),
+ __watcher_storagePath__ = $(`[data-option="watcherStoragePath"]`),
+ __radarr_qualityProfile__ = $(`[data-option="radarrQualityProfileId"]`),
+ __radarr_storagePath__ = $(`[data-option="radarrStoragePath"]`),
+ __sonarr_qualityProfile__ = $(`[data-option="sonarrQualityProfileId"]`),
+ __sonarr_storagePath__ = $(`[data-option="sonarrStoragePath"]`),
+ __save__ = $('#save'),
+ __options__ = [
+ /* Plex Settings */
+ 'plexURL',
+ 'plexToken',
+ 'UseOmbi',
+ 'preferredServer',
+
+ /* Manager Settings */
+ // Ombi
+ 'usingOmbi',
+ 'ombiURLRoot',
+ 'ombiToken',
+
+ // Medusa
+ 'usingMedusa',
+ 'medusaURLRoot',
+ 'medusaToken',
+ 'medusaBasicAuthUsername',
+ 'medusaBasicAuthPassword',
+ 'medusaStoragePath',
+ 'medusaQualityProfileId',
+
+ // Watcher
+ 'usingWatcher',
+ 'watcherURLRoot',
+ 'watcherToken',
+ 'watcherBasicAuthUsername',
+ 'watcherBasicAuthPassword',
+ 'watcherStoragePath',
+ 'watcherQualityProfileId',
+
+ // Radarr
+ 'usingRadarr',
+ 'radarrURLRoot',
+ 'radarrToken',
+ 'radarrBasicAuthUsername',
+ 'radarrBasicAuthPassword',
+ 'radarrStoragePath',
+ 'radarrQualityProfileId',
+
+ // Sonarr
+ 'usingSonarr',
+ 'sonarrURLRoot',
+ 'sonarrToken',
+ 'sonarrBasicAuthUsername',
+ 'sonarrBasicAuthPassword',
+ 'sonarrStoragePath',
+ 'sonarrQualityProfileId',
+
+ // Sick Beard
+ 'usingSickBeard',
+ 'sickBeardURLRoot',
+ 'sickBeardToken',
+ 'sickBeardBasicAuthUsername',
+ 'sickBeardBasicAuthPassword',
+ 'sickBeardStoragePath',
+ 'sickBeardQualityProfileId',
+
+ // CouchPotato
+ 'enableCouchPotato',
+ 'usingCouchPotato',
+ 'couchpotatoURLRoot',
+ 'couchpotatoToken',
+ 'couchpotatoBasicAuthUsername',
+ 'couchpotatoBasicAuthPassword',
+ // 'couchpotatoQualityProfileId',
+
+ /* Other Settings */
+ // Connection settings
+ 'UseProxy',
+ 'ProxyURL',
+ 'ProxyHeaders',
+
+ // Media settings
+ 'UseAutoGrab',
+ 'AutoGrabLimit',
+ 'PromptLocation',
+ 'PromptQuality',
+
+ // Notification Settings
+ 'NotifyNewOnly',
+ 'NotifyOnlyOnce',
+
+ // Search Settings
+ 'UseLoose',
+ 'UseLooseScore',
+ 'ManagerSearch',
+
+ // Advance Settings
+ 'OMDbAPI',
+ 'TMDbAPI',
+ 'UseLZW',
+ 'DeveloperMode',
+
+ // Hidden values
+ 'watcherQualities',
+ 'radarrQualities',
+ 'sonarrQualities',
+ 'medusaQualities',
+ 'sickBeardQualities',
+ 'watcherStoragePaths',
+ 'radarrStoragePaths',
+ 'sonarrStoragePaths',
+ 'medusaStoragePaths',
+ 'sickBeardStoragePaths',
+ '__radarrQuality',
+ '__sonarrQuality',
+ '__medusaQuality',
+ '__sickBeardQuality',
+ '__radarrStoragePath',
+ '__sonarrStoragePath',
+ '__medusaStoragePath',
+ '__sickBeardStoragePath',
+ '__domains',
+ '__caught',
+ '__theme',
+
+ // Builtins - End of file, "let builtins ="
+ 'builtin_allocine',
+ 'builtin_amazon',
+ 'builtin_couchpotato',
+ 'builtin_fandango',
+ 'builtin_flickmetrix',
+ 'builtin_google',
+ 'builtin_googleplay',
+ 'builtin_hulu',
+ 'builtin_imdb',
+ 'builtin_justwatch',
+ 'builtin_letterboxd',
+ 'builtin_metacritic',
+ 'builtin_moviemeter',
+ 'builtin_movieo',
+ 'builtin_netflix',
+ 'builtin_plex',
+ 'builtin_rottentomatoes',
+ 'builtin_shanaproject',
+ 'builtin_showrss',
+ 'builtin_tmdb',
+ 'builtin_tvmaze',
+ 'builtin_tvdb',
+ 'builtin_trakt',
+ 'builtin_vrv',
+ 'builtin_verizon',
+ 'builtin_vudu',
+ 'builtin_vumoo',
+ 'builtin_youtube',
+ 'builtin_itunes',
+ 'builtin_gostream',
+ 'builtin_tubi',
+ 'builtin_webtoplex',
+
+ // Plugins - End of file, "let plugins ="
+ 'plugin_toloka',
+ 'plugin_shanaproject',
+ 'plugin_myanimelist',
+ 'plugin_myshows',
+ 'plugin_indomovie',
+ 'plugin_redbox',
+ 'plugin_kitsu',
+ 'plugin_go',
+ 'plugin_snagfilms',
+ 'plugin_freemoviescinema',
+ 'plugin_foxsearchlight',
+
+ // Theme Settings
+ ...(() => [...$('[data-option^="theme:"i]', true)].map(e => e.dataset.option))()
+ ];
+
+let PlexServers = [],
+ ServerID = null,
+ ClientID = null,
+ manifest = chrome.runtime.getManifest(),
+ terminal = // See #3
+ (DEVELOPER_MODE = $('[data-option="DeveloperMode"]').checked)?
+ console:
+ { error: m => m, info: m => m, log: m => m, warn: m => m, group: m => m, groupEnd: m => m };
+
+chrome.manifest = manifest;
+
+// Not really important variables
+// The "caught" IDs (already asked for in managers)
+let __caught = {
+ imdb: [],
+ tmdb: [],
+ tvdb: [],
+},
+// The theme classes
+ __theme = [];
+
+// create and/or queue a notification
+// state = "error" - red
+// state = "update" - blue
+// state = "info" - grey
+// anything else for state will show as orange
+class Notification {
+ constructor(state, text, timeout = 7000, callback = () => {}, requiresClick = true) {
+ let queue = (Notification.queue = Notification.queue || { list: [] }),
+ last = queue.list[queue.list.length - 1];
+
+ if (last && last.done === false)
+ return (last => setTimeout(() => new Notification(state, text, timeout, callback, requiresClick), +(new Date) - last.start))(last);
+
+ let element = document.furnish(`div.notification.${state}`, {
+ onclick: event => {
+ let notification = Notification.queue[event.target.id],
+ element = notification.element;
+
+ notification.done = true;
+ Notification.queue.list.splice(notification.index, 1);
+ clearTimeout(notification.job);
+ element.remove();
+
+ let removed = delete Notification.queue[notification.id];
+
+ return (event.requiresClick)? null: notification.callback(removed);
+ }
+ }, text);
+
+ queue[element.id = +(new Date)] = {
+ start: +element.id,
+ stop: +element.id + timeout,
+ span: +timeout,
+ done: false,
+ index: queue.list.length,
+ job: setTimeout(() => element.onclick({ target: element, requiresClick }), timeout),
+ id: +element.id,
+ callback, element
+ };
+ queue.list.push(queue[element.id]);
+
+ document.body.appendChild(element);
+
+ return queue[element.id];
+ }
+}
+
+class Prompt {
+ constructor(type, options, callback = () => {}, container = document.body) {
+ let prompt, remove, create,
+ array = (options instanceof Array? options: [].slice.call(options)),
+ data = [...array];
+
+ switch(type) {
+ /* Allows the user to add and remove items from a list */
+ case 'prompt':
+ case 'input':
+ remove = element => {
+ let prompter = document.querySelector('.prompt'),
+ header = document.querySelector('.prompt-header'),
+ counter = document.querySelector('.prompt-options');
+
+ if(element === true)
+ return prompter.remove();
+ else
+ element.remove();
+
+ data.splice(+element.value, 1, null);
+ header.innerText = 'Approve ' + counter.children.length + (counter.children.length == 1?' item': ' items');
+ };
+
+ prompt = document.furnish('div.prompt', {},
+ document.furnish('div.prompt-body', {},
+ // The prompt's title
+ document.furnish('h1.prompt-header', {}, 'Approve ' + array.length + (array.length == 1? ' item': ' items')),
+
+ // The prompt's items
+ document.furnish('div.prompt-options', {},
+ ...(create = ITEMS => {
+ let elements = [];
+
+ for(let index = 0, length = ITEMS.length, ITEM; index < length; index++)
+ ITEM = ITEMS[index],
+ elements.push(
+ document.furnish('li.prompt-option.mutable', { value: index },
+ JSON.stringify(ITEM),
+ document.furnish('button', { title: 'Remove', onclick: event => { remove(event.target.parentElement); event.target.remove() } })
+ )
+ );
+
+ return elements
+ })(array)
+ ),
+
+ // The engagers
+ document.furnish('div.prompt-footer', {},
+ document.furnish('input.prompt-input[type=text]', { placeholder: 'Add an item (enter to add)', onkeydown: event => {
+ let self = event.target;
+
+ if (event.keyCode === 13) {
+ event.preventDefault();
+ remove(true);
+
+ let value = self.value;
+
+ try {
+ value = JSON.parse(value);
+ } catch(error) {
+ /* Suppress input errors */
+ }
+
+ new Prompt(type, [value, ...data.filter(value => value !== null && value !== undefined)], callback, container);
+ }
+ } }),
+ document.furnish('button.prompt-decline', { onclick: event => { remove(true); callback([]) } }, 'Close'),
+ document.furnish('button.prompt-accept', { onclick: event => { remove(true); new Prompt(type, options, callback, container) } }, 'Reset'),
+ document.furnish('button.prompt-accept', { onclick: event => { remove(true); callback(data.filter(value => value !== null && value !== undefined)) } }, 'Continue')
+ )
+ )
+ );
+ break;
+
+ /* Allows the user to remove predetermined items */
+ case 'select':
+ remove = element => {
+ let prompter = document.querySelector('.prompt'),
+ header = document.querySelector('.prompt-header'),
+ counter = document.querySelector('.prompt-options');
+
+ if(element === true)
+ return prompter.remove();
+ else
+ element.remove();
+
+ data.splice(+element.value, 1, null);
+ header.innerText = 'Approve ' + counter.children.length + (counter.children.length == 1?' item': ' items');
+ };
+
+ prompt = document.furnish('div.prompt', {},
+ document.furnish('div.prompt-body', {},
+ // The prompt's title
+ document.furnish('h1.prompt-header', {}, 'Approve ' + array.length + (array.length == 1? ' item': ' items')),
+
+ // The prompt's items
+ document.furnish('div.prompt-options', {},
+ ...(create = ITEMS => {
+ let elements = [];
+
+ for(let index = 0, length = ITEMS.length, ITEM; index < length; index++)
+ ITEM = ITEMS[index],
+ elements.push(
+ document.furnish('li.prompt-option.mutable', { value: index },
+ JSON.stringify(ITEM),
+ document.furnish('button', { title: 'Remove', onclick: event => { remove(event.target.parentElement); event.target.remove() } })
+ )
+ );
+
+ return elements
+ })(array)
+ ),
+
+ // The engagers
+ document.furnish('div.prompt-footer', {},
+ document.furnish('button.prompt-decline', { onclick: event => { remove(true); callback([]) } }, 'Close'),
+ document.furnish('button.prompt-accept', { onclick: event => { remove(true); new Prompt(type, options, callback, container) } }, 'Reset'),
+ document.furnish('button.prompt-accept', { onclick: event => { remove(true); callback(data.filter(value => value !== null && value !== undefined)) } }, 'Continue')
+ )
+ )
+ );
+ break;
+
+ default:
+ return terminal.warn(`Unknown prompt type "${ type }"`);
+ break;
+ }
+
+ return container.append(prompt), prompt;
+ }
+}
+
+function TLDHost(host) {
+ return host.replace(/^(ww\w+|\w{2})\./, '');
+}
+
+function addListener(element, eventName, callback = event => {}) {
+ eventName = eventName.replace(/^(on)?/, 'on');
+ callback = callback.toString().replace(/;+$/g, '');
+
+ let event = element.getAttribute(eventName);
+
+ if(event && event.length)
+ event = `${ event }; ${ callback }`;
+ else
+ event = callback;
+
+ element[eventName] = eval(event);
+}
+
+function traverse(element, until, siblings = false) {
+ let elements;
+
+ if(siblings) {
+ if(element instanceof Array || element instanceof NodeList) {
+ for(elements = [...element], element = elements[0]; element && until(element) === false;)
+ if(element.previousElementSibling)
+ element = element.previousElementSibling;
+ else
+ elements.splice(0, 1),
+ element = elements[0];
+ } else {
+ while(until(element) === false && element)
+ element = element.previousElementSibling || element.parentElement;
+ }
+ }
+
+ if(element instanceof Array || element instanceof NodeList) {
+ for(element = [...element]; element.length && until(element[0]) === false; element.splice(0, 1))
+ continue;
+ element = element[0];
+ } else {
+ while(until(element) === false && element)
+ element = element.parentElement;
+ }
+
+ return element;
+}
+
+function LoadingAnimation(state = false) {
+ $('display').setAttribute('loading', state);
+}
+
+function load(name, decompress_data = false) {
+ let options = JSON.stringify(getOptionValues()),
+ data;
+
+ name = btoa(name);
+ data = localStorage.getItem(name);
+
+ if(decompress_data)
+ data = iBWT(decompress(data));
+
+ return JSON.parse(data);
+}
+
+function save(name, data, compress_data = false) {
+ let options = JSON.stringify(getOptionValues());
+
+ name = btoa(name);
+ data = JSON.stringify(data);
+
+ if(compress_data)
+ data = compress(BWT(data));
+
+ return localStorage.setItem(name, data);
+}
+
+function getServers(plexToken) {
+ return fetch('https://plex.tv/api/resources?includeHttps=1', {
+ headers: {
+ 'X-Plex-Token': plexToken
+ }
+ })
+ .then(response => response.text())
+ .then(xml => {
+ let data = parseXML(xml);
+
+ if(/^\s*Invalid/i.test(data))
+ throw data;
+
+ return data.Device.filter(device => !!~device.provides.split(',').indexOf('server'));
+ })
+ .catch(error => {
+ new Notification('error', `Unable to connect to Plex: "${ error }"`, 7000);
+
+ throw error;
+ });
+}
+
+/* See #1 */
+function tryPlexLogin(username, password) {
+ let hash = btoa(`${username}:${password}`);
+
+ return fetch(`https://plex.tv/users/sign_in.json`, {
+ method: 'POST',
+ headers: {
+ 'X-Plex-Product': 'Web to Plex',
+ 'X-Plex-Version': manifest.version,
+ 'X-Plex-Client-Identifier': ClientID,
+ 'Authorization': `Basic ${ hash }`
+ }
+ })
+ .then(response => response.json());
+}
+
+function performPlexLogin({ event }) {
+ let u = $('#plex_username').value,
+ p = $('#plex_password').value,
+ s = $('#plex_test_status');
+
+ s.title = '';
+ __servers__.innerHTML = '';
+ __save__.disabled = true;
+ LoadingAnimation(true);
+
+ tryPlexLogin(u, p)
+ .then(response => {
+ LoadingAnimation();
+
+ if(response.error)
+ return s.title = 'Invalid login information', null;
+
+ if(response.user) {
+ let t = $('#plex_token');
+
+ ClientID = t.value = t.textContent = response.user.authToken;
+
+ return performPlexTest({});
+ }
+ });
+
+}
+
+function performPlexTest({ ServerID, event }) {
+ let plexToken = $('#plex_token').value,
+ teststatus = $('#plex_test_status'),
+ inusestatus = [...$('[using="plex"]', true)];
+
+ __save__.disabled = true;
+ __servers__.innerHTML = '';
+ teststatus.textContent = '?';
+ inusestatus.map(e => e.setAttribute('in-use', false));
+ LoadingAnimation(true);
+
+ getServers(plexToken).then((servers = []) => {
+ LoadingAnimation();
+
+ PlexServers = servers;
+ teststatus.textContent = '!';
+ inusestatus.map(e => e.setAttribute('in-use', false));
+
+ if(!servers)
+ return teststatus.title = 'Failed to communicate with Plex', teststatus.classList = false;
+ inusestatus.map(e => e.setAttribute('in-use', true));
+
+ __save__.disabled = false;
+ teststatus.classList = true;
+
+ (servers = [{ sourceTitle: 'GitHub', clientIdentifier: '', name: 'No Server', notice: 'This will not connect to any Plex servers' }, ...servers]).forEach(server => {
+ let $option = document.createElement('option'),
+ source = server.sourceTitle;
+
+ $option.value = server.clientIdentifier;
+ $option.textContent = `${ server.name }${ source ? ` \u2014 ${ source }` : '' }`;
+ $option.title = server.notice || (source? `"${ server.sourceTitle }" owns this server`: '');
+ __servers__.appendChild($option);
+ });
+
+ if(ServerID) {
+ __servers__.value = ServerID;
+ }
+ });
+}
+
+function getPlexConnections(server) {
+ // `server.Connection` can be an array or object.
+ let connections = [];
+
+ if(server.Connection instanceof Array)
+ connections = server.Connection;
+ else
+ connections = [server.Connection];
+
+ return connections.map(connection => ({
+ uri: connection.uri,
+ local: connection.local === '1'
+ }));
+}
+
+function getOptionValues() {
+ let options = {};
+
+ __options__.forEach(option => {
+ let element = $(`[data-option="${ option }"]`);
+
+ if(element) {
+ if(element.type == 'checkbox')
+ options[option] = element.checked;
+ else
+ options[option] = element.value;
+ }
+ });
+
+ let COM = options.UseLZW;
+
+ for(let key in __caught)
+ __caught[key] = __caught[key].filter(id => id).slice(0, (COM? 200: 100)).sort();
+
+ __theme = __theme.filter(v => v);
+
+ let _c = JSON.stringify(__caught),
+ _t = JSON.stringify(__theme);
+
+ $('[data-option="__caught"i]').value = options.__caught = (COM? compress(BWT(_c)): _c);
+ $('[data-option="__theme"i]').value = options.__theme = (COM? compress(BWT(_t)): _t);
+
+ return options;
+}
+
+function performOmbiLogin({ event }) {
+ let l = $('#ombi_url').value,
+ a = $('#ombi_api').value,
+ s = $('#plex_test_status'),
+ e = ($0, $1, $$, $_) => ($1 + (/\\/.test($_)? '\\': '/'));
+
+ l = l
+ .replace(/([^\\\/])$/, e)
+ .replace(/^(?!^http(s)?:\/\/)(.+)/, 'http$1://$2');
+ s.title = '';
+ __servers__.innerHTML = '';
+ __save__.disabled = true;
+ LoadingAnimation(true);
+
+ let APIURL = `${ l }api/v1/`,
+ headers = { headers: { apikey: a, accept: 'application/json' } };
+
+ fetch(`${ APIURL }Settings/plex`, headers)
+ .then( response => response.json() )
+ .then( json => {
+ /* Get Plex's details first. If it's disabled, or non-existent, then exit */
+ /* Swagger API says "enable", but we'll go with "enabled" */
+ if(json && (json.enable || json.enabled)) {
+ let t = $('#plex_token'),
+ u = $('[data-option="UseOmbi"]'),
+ s = __servers__;
+
+ json = (json && json.servers.length? json.servers[0]: {});
+
+ let name = json.name, // people friendly server name
+ token = json.plexAuthToken, // the auth token
+ uuid = json.machineIdentifier, // the machine ID
+ url = json.ip; // the Plex URL used
+
+ url = url.replace(/(?:[^\/]+\/\/)?([^\/]+)\/?/, `http${ json.ssl? 's': '' }://$1:${ json.port }/`);
+
+ ClientID = t.value = t.textContent = token;
+ ServerID = s.value = uuid;
+ s.innerHTML = `${ name } `;
+
+ /* Now we can fill in the other details */
+ if(u.checked) {
+ // Ombi
+ let L = $('[data-option="ombiURLRoot"]'),
+ A = $('[data-option="ombiToken"]');
+
+ L.value = L.textContent = l;
+ A.value = A.textContent = a;
+
+ new Notification('update', 'Filled in Ombi', 3000);
+
+ // CouchPotato
+ fetch(`${ APIURL }Settings/CouchPotato`, headers)
+ .then( data => data.json() )
+ .then( json => {
+ LoadingAnimation();
+ if(!json || (!json.enabled && !json.enable)) return;
+
+ let k = $('[data-option="couchpotatoToken"]'),
+ K = $('[data-option="couchpotatoURLRoot"]');
+
+ k.value = k.textContent = json.apiKey;
+ K.value = K.textContent = json.ip.replace(/(?:[^\/]+\/\/)?([^\/]+)\/?/, `http${ json.ssl? 's': '' }://$1:${ json.port }/`);
+
+ new Notification('update', 'Filled in CouchPotato', 3000);
+ } )
+ .catch( error => { new Notification('error', 'Error getting CouchPotato details from Ombi'); throw error } );
+
+ // Radarr
+ fetch(`${ APIURL }Settings/radarr`, headers)
+ .then( data => data.json() )
+ .then( json => {
+ LoadingAnimation();
+ if(!json || (!json.enabled && !json.enable)) return;
+
+ let k = $('[data-option="radarrToken"]'),
+ K = $('[data-option="radarrURLRoot"]'),
+ q = $('[data-option="radarrQualityProfileId"]'),
+ Q = $('[data-option="radarrStoragePath"]'),
+ _q, _Q;
+
+ k.value = k.textContent = json.apiKey;
+ K.value = K.textContent = json.ip.replace(/(?:[^\/]+\/\/)?([^\/]+)\/?/, `http${ json.ssl? 's': '' }://$1:${ json.port }/`);
+ q.value = _q = json.defaultQualityProfile;
+ Q.value = _Q = json.defaultRootPath;
+
+ q.innerHTML = `[Ombi]: ${ _q } `;
+ Q.innerHTML = `[Ombi]: ${ _Q } `;
+
+ new Notification('update', 'Filled in Radarr', 3000);
+ } )
+ .catch( error => { new Notification('error', 'Error getting Radarr details from Ombi'); throw error } );
+
+ // Sonarr
+ fetch(`${ APIURL }Settings/sonarr`, headers)
+ .then( data => data.json() )
+ .then( json => {
+ LoadingAnimation();
+ if(!json || (!json.enabled && !json.enable)) return;
+
+ let k = $('[data-option="sonarrToken"]'),
+ K = $('[data-option="sonarrURLRoot"]'),
+ q = $('[data-option="sonarrQualityProfileId"]'),
+ Q = $('[data-option="sonarrStoragePath"]'),
+ _q, _Q;
+
+ k.value = k.textContent = json.apiKey;
+ K.value = K.textContent = json.ip.replace(/(?:[^\/]+\/\/)?([^\/]+)\/?/, `http${ json.ssl? 's': '' }://$1:${ json.port }/`);
+ q.value = _q = json.qualityProfile;
+ Q.value = _Q = json.rootPath;
+
+ q.innerHTML = `[Ombi]: ${ _q } `;
+ Q.innerHTML = `[Ombi]: ${ _Q } `;
+
+ new Notification('update', 'Filled in Sonarr', 3000);
+ } )
+ .catch( error => { new Notification('error', 'Error getting Sonarr details from Ombi'); throw error } );
+ }
+
+ __save__.disabled = false;
+ } else {
+ /* Plex either doesn't exist, or is disabled */
+ new Notification('error', 'Error getting Plex details from Ombi');
+ }
+ } )
+ .catch( error => { new Notification('error', error); throw error } );
+}
+
+function performOmbiTest({ refreshing = false, event }) {
+ let options = getOptionValues(),
+ teststatus = $('#ombi_test_status'),
+ path = $('[data-option="ombiURLRoot"]'),
+ url,
+ headers = { headers: { apikey: options.ombiToken, accept: 'text/html' } },
+ enabled = refreshing? $('#using-ombi'): $$('#using-ombi'),
+ inusestatus = [...$('[using="ombi"]', true)];
+
+ teststatus.textContent = '?';
+ options.ombiURLRoot = url = path.value = options.ombiURLRoot.replace(/^(\:\d+)/, 'localhost$1').replace(/^(?!^http(s)?:)/, 'http$1://').replace(/\/+$/, '');
+ inusestatus.map(e => e.setAttribute('in-use', false));
+ LoadingAnimation(true);
+
+ let Get = () => {
+ try {
+ fetch(`${ url }/api/v1/Request/movie`)
+ .then(r => r.json())
+ .thne(json => {
+ json.map(item => {
+ __caught.imdb.push(item.imdbId);
+ __caught.tmdb.push(item.theMovieDbId);
+ });
+ });
+
+ fetch(`${ url }/api/v1/Request/tv`)
+ .then(r => r.json())
+ .thne(json => {
+ json.map(item => {
+ __caught.imdb.push(item.imdbId);
+ __caught.tvdb.push(item.tvDbId);
+ });
+ });
+
+ fetch(`${ url }/api/v1/Status`, headers)
+ .then( response => response.text() )
+ .then( status => {
+ LoadingAnimation();
+ if (!status || !status.length) throw new Error('Unable to communicate with Ombi');
+
+ if ((status = +status) >= 200 && status < 400) {
+ teststatus.textContent = '!';
+ enabled.checked = teststatus.classList = true;
+ enabled.parentElement.removeAttribute('disabled');
+ inusestatus.map(e => e.setAttribute('in-use', true));
+ } else {
+ teststatus.textContent = '!';
+ enabled.checked = teststatus.classList = false;
+ enabled.parentElement.setAttribute('disabled');
+ inusestatus.map(e => e.setAttribute('in-use', false));
+
+ throw new Error(`Ombi error [${ status }]`);
+ }
+ } )
+ .catch( error => { new Notification('error', error) } );
+ } catch(error) {
+ LoadingAnimation();
+
+ throw error;
+ }
+ }
+
+ if(refreshing)
+ Get();
+ else if(url && url.length)
+ requestURLPermissions(url + '/*', allowed =>
+ (allowed)?
+ Get():
+ new Notification('error', 'The user refused permission to access Ombi')
+ );
+}
+
+function getWatcher(options, api = "getconfig") {
+ if(!options.watcherToken)
+ return new Notification('error', 'Invalid Watcher token');
+
+ let headers = {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ 'X-Api-Key': options.watcherToken
+ };
+
+ if(options.watcherBasicAuthUsername)
+ headers.Authorization = `Basic ${ btoa(`${ options.watcherBasicAuthUsername }:${ options.watcherBasicAuthPassword }`) }`;
+
+ return fetch(`${ options.watcherURLRoot }/api/?apikey=${ options.watcherToken }&mode=${ api }&quality=${ options.watcherQualityProfileId || 'Default' }`, { headers })
+ .then(response => response.json())
+ .catch(error => {
+ return new Notification('error', 'Watcher failed to connect with error:' + String(error)),
+ [];
+ });
+}
+
+function performWatcherTest({ QualityProfileID = 'Default', refreshing = false, event }) {
+ let options = getOptionValues(),
+ teststatus = $('#watcher_test_status'),
+ path = $('[data-option="watcherURLRoot"]'),
+ storagepath = __watcher_storagePath__,
+ quality = __watcher_qualityProfile__,
+ url,
+ enabled = refreshing? $('#using-watcher'): $$('#using-watcher'),
+ inusestatus = [...$('[using="watcher"]', true)];
+
+ quality.innerHTML = '';
+ teststatus.textContent = '?';
+ storagepath.value = '[Empty]';
+ options.watcherURLRoot = url = path.value = options.watcherURLRoot.replace(/^(\:\d+)/, 'localhost$1').replace(/^(?!^http(s)?:)/, 'http$1://').replace(/\/+$/, '');
+ inusestatus.map(e => e.setAttribute('in-use', false));
+ LoadingAnimation(true);
+
+ let Get = () => {
+ try {
+ getWatcher(options, 'liststatus').then(list => {
+ list.map(item => {
+ __caught.imdb.push(item.movies.imdbid);
+ __caught.tmdb.push(item.movies.tmdbid);
+ });
+ });
+
+ getWatcher(options, 'getconfig').then(configuration => {
+ LoadingAnimation();
+ if(!configuration || !configuration.response) return new Notification('error', 'Failed to get Watcher configuration');
+
+ let names = configuration.config.Quality.Profiles,
+ path = configuration.config.Postprocessing.moverpath,
+ syntax = path.replace(/\/([\w\s\/\\\{\}]+)$/, '$1'),
+ profiles = [];
+
+ path = path.replace(syntax, '');
+
+ for(let name in names)
+ profiles.push({
+ id: name,
+ name
+ });
+
+ teststatus.textContent = '!';
+ teststatus.classList = enabled.checked = !!profiles.length;
+ inusestatus.map(e => e.setAttribute('in-use', enabled.checked));
+
+ if(!profiles.length)
+ return teststatus.title = 'Failed to communicate with Watcher';
+ enabled.parentElement.removeAttribute('disabled');
+
+ let qualities = [];
+ profiles.forEach(profile => {
+ let option = document.createElement('option');
+ let { id, name } = profile;
+
+ option.value = id;
+ option.textContent = name;
+ qualities.push({ id, name });
+ quality.appendChild(option);
+ });
+
+ $('[data-option="watcherQualities"i]').value = JSON.stringify(qualities);
+
+ // Because the was reset, the original value is lost.
+ if(QualityProfileID)
+ quality.value = QualityProfileID;
+
+ storagepath.value = path || '[Default Location]';
+
+ $('[data-option="watcherStoragePaths"i]').value = JSON.stringify(path || { path: '[Default Location]', id: 0 });
+ });
+ } catch(error) {
+ LoadingAnimation();
+
+ throw error;
+ }
+ }
+
+ if(refreshing)
+ Get();
+ else if(url && url.length)
+ requestURLPermissions(url + '/*', allowed =>
+ (allowed)?
+ Get():
+ new Notification('error', 'The user refused permission to access Watcher')
+ );
+}
+
+function getRadarr(options, api = "profile") {
+ if(!options.radarrToken)
+ return new Notification('error', 'Invalid Radarr token');
+
+ let headers = {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ 'X-Api-Key': options.radarrToken
+ };
+
+ if(options.radarrBasicAuthUsername)
+ headers.Authorization = `Basic ${ btoa(`${ options.radarrBasicAuthUsername }:${ options.radarrBasicAuthPassword }`) }`;
+
+ return fetch(`${ options.radarrURLRoot }/api/${ api }`, { headers })
+ .then(response => response.json())
+ .catch(error => {
+ return new Notification('error', 'Radarr failed to connect with error:' + String(error)),
+ [];
+ });
+}
+
+function performRadarrTest({ QualityProfileID, StoragePath, refreshing = false, event }) {
+ let options = getOptionValues(),
+ teststatus = $('#radarr_test_status'),
+ path = $('[data-option="radarrURLRoot"]'),
+ storagepath = __radarr_storagePath__,
+ quality = __radarr_qualityProfile__,
+ url,
+ enabled = refreshing? $('#using-radarr'): $$('#using-radarr'),
+ inusestatus = [...$('[using="radarr"]', true)];
+
+ quality.innerHTML = '';
+ teststatus.textContent = '?';
+ storagepath.textContent = '';
+ options.radarrURLRoot = url = path.value = options.radarrURLRoot.replace(/^(\:\d+)/, 'localhost$1').replace(/^(?!^http(s)?:)/, 'http$1://').replace(/\/+$/, '');
+ inusestatus.map(e => e.setAttribute('in-use', false));
+ LoadingAnimation(true);
+
+ let Get = () => {
+ try {
+ getRadarr(options, 'movie').then(movies => {
+ movies.map(movie => {
+ __caught.imdb.push(movie.imdbId);
+ __caught.tmdb.push(movie.tmdbId);
+ });
+ });
+
+ getRadarr(options, 'profile').then(profiles => {
+ LoadingAnimation();
+ if(!profiles) return new Notification('error', 'Failed to get Radarr configuration');
+
+ teststatus.textContent = '!';
+ teststatus.classList = enabled.checked = !!profiles.length;
+ inusestatus.map(e => e.setAttribute('in-use', enabled.checked));
+
+ if(!profiles.length)
+ return teststatus.title = 'Failed to communicate with Radarr';
+ enabled.parentElement.removeAttribute('disabled');
+
+ let qualities = [];
+ profiles.forEach(profile => {
+ let option = document.createElement('option');
+ let { id, name } = profile;
+
+ option.value = id;
+ option.textContent = name;
+ qualities.push({ id, name });
+ quality.appendChild(option);
+ });
+
+ $('[data-option="radarrQualities"i]').value = JSON.stringify(qualities);
+
+ // Because the was reset, the original value is lost.
+ if(QualityProfileID)
+ $('[data-option="__radarrQuality"i]').value = quality.value = QualityProfileID;
+ });
+
+ let StoragePaths = [];
+ getRadarr(options, 'rootfolder').then(storagepaths => {
+ storagepaths.forEach(path => {
+ let option = document.createElement('option');
+
+ StoragePaths.push((option.value = option.textContent = path.path).replace(/\\/g, '/'));
+ storagepath.appendChild(option);
+ });
+
+ $('[data-option="radarrStoragePaths"i]').value = JSON.stringify(storagepaths);
+
+ // Because the was reset, the original value is lost.
+ if(StoragePath) {
+ storagepath.value = StoragePath;
+ $('[data-option="__radarrStoragePath"i]').value = StoragePaths.indexOf(StoragePath.replace(/\\/g, '/')) + 1;
+ }
+ });
+ } catch(error) {
+ LoadingAnimation();
+
+ throw error;
+ }
+ };
+
+ if(refreshing)
+ Get();
+ else if(url && url.length)
+ requestURLPermissions(url + '/*', allowed =>
+ (allowed)?
+ Get():
+ new Notification('error', 'The user refused permission to access Radarr')
+ );
+}
+
+function getSonarr(options, api = "profile") {
+ if(!options.sonarrToken)
+ return new Notification('error', 'Invalid Sonarr token');
+
+ let headers = {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ 'X-Api-Key': options.sonarrToken
+ };
+
+ if(options.sonarrBasicAuthUsername)
+ headers.Authorization = `Basic ${ btoa(`${ options.sonarrBasicAuthUsername }:${ options.sonarrBasicAuthPassword }`) }`;
+
+ return fetch(`${ options.sonarrURLRoot }/api/${ api }`, { headers })
+ .then(response => response.json())
+ .catch(error => {
+ return new Notification('error', 'Sonarr failed to connect with error:' + String(error)),
+ [];
+ });
+}
+
+function performSonarrTest({ QualityProfileID, StoragePath, refreshing = false, event }) {
+ let options = getOptionValues(),
+ teststatus = $('#sonarr_test_status'),
+ path = $('[data-option="sonarrURLRoot"]'),
+ storagepath = __sonarr_storagePath__,
+ quality = __sonarr_qualityProfile__,
+ url,
+ enabled = refreshing? $('#using-sonarr'): $$('#using-sonarr'),
+ inusestatus = [...$('[using="sonarr"]', true)];
+
+ quality.innerHTML = '';
+ teststatus.textContent = '?';
+ storagepath.textContent = '';
+ options.sonarrURLRoot = url = path.value = options.sonarrURLRoot.replace(/^(\:\d+)/, 'localhost$1').replace(/^(?!^http(s)?:)/, 'http$1://').replace(/\/+$/, '');
+ inusestatus.map(e => e.setAttribute('in-use', false));
+ LoadingAnimation(true);
+
+ let Get = () => {
+ try {
+ getSonarr(options, 'series').then(shows => {
+ shows.map(show => {
+ __caught.tvdb.push(show.tvdbId);
+ });
+ });
+
+ getSonarr(options, 'profile').then(profiles => {
+ LoadingAnimation();
+ if(!profiles) return new Notification('error', 'Failed to get Sonarr configuration');
+
+ teststatus.textContent = '!';
+ teststatus.classList = enabled.checked = !!profiles.length;
+ inusestatus.map(e => e.setAttribute('in-use', enabled.checked));
+
+ if(!profiles.length)
+ return teststatus.title = 'Failed to communicate with Sonarr';
+ enabled.parentElement.removeAttribute('disabled');
+
+ let qualities = [];
+ profiles.forEach(profile => {
+ let option = document.createElement('option');
+ let { id, name } = profile;
+
+ option.value = id;
+ option.textContent = name;
+ qualities.push({ id, name });
+ quality.appendChild(option);
+ });
+
+ $('[data-option="sonarrQualities"i]').value = JSON.stringify(qualities);
+
+ // Because the was reset, the original value is lost.
+ if(QualityProfileID)
+ $('[data-option="__sonarrQuality"i]').value = quality.value = QualityProfileID;
+ });
+
+ let StoragePaths = [];
+ getSonarr(options, 'rootfolder').then(storagepaths => {
+ storagepaths.forEach(path => {
+ let option = document.createElement('option');
+
+ StoragePaths.push((option.value = option.textContent = path.path).replace(/\\/g, '/'));
+ storagepath.appendChild(option);
+ });
+
+ $('[data-option="sonarrStoragePaths"i]').value = JSON.stringify(storagepaths);
+
+ // Because the was reset, the original value is lost.
+ if(StoragePath) {
+ storagepath.value = StoragePath;
+ $('[data-option="__sonarrStoragePath"i]').value = StoragePaths.indexOf(StoragePath.replace(/\\/g, '/')) + 1;
+ }
+ });
+ } catch(error) {
+ LoadingAnimation();
+
+ throw error;
+ }
+ };
+
+ if(refreshing)
+ Get();
+ else if(url && url.length)
+ requestURLPermissions(url + '/*', allowed =>
+ (allowed)?
+ Get():
+ new Notification('error', 'The user refused permission to access Sonarr')
+ );
+}
+
+function getMedusa(options, api = 'config') {
+ if(!options.medusaToken)
+ return new Notification('error', 'Invalid Medusa token');
+
+ let headers = {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ 'X-Api-Key': options.medusaToken
+ };
+
+ if(options.medusaBasicAuthUsername)
+ headers.Authorization = `Basic ${ btoa(`${ options.medusaBasicAuthUsername }:${ options.medusaBasicAuthPassword }`) }`;
+
+ return fetch(`${ options.medusaURLRoot }/api/v2/${ api }`, { headers })
+ .then(response => response.json())
+ .catch(error => {
+ return new Notification('error', 'Medusa failed to connect with error:' + String(error)),
+ [];
+ });
+}
+
+function performMedusaTest({ QualityProfileID, StoragePath, refreshing = false, event }) {
+ let options = getOptionValues(),
+ teststatus = $('#medusa_test_status'),
+ path = $('[data-option="medusaURLRoot"]'),
+ storagepath = __medusa_storagePath__,
+ quality = __medusa_qualityProfile__,
+ url,
+ enabled = refreshing? $('#using-medusa'): $$('#using-medusa'),
+ inusestatus = [...$('[using="medusa"]', true)];
+
+ quality.innerHTML = '';
+ teststatus.textContent = '?';
+ storagepath.textContent = '';
+ options.medusaURLRoot = url = path.value = options.medusaURLRoot.replace(/^(\:\d+)/, 'localhost$1').replace(/^(?!^http(s)?:)/, 'http$1://').replace(/\/+$/, '');
+ inusestatus.map(e => e.setAttribute('in-use', false));
+ LoadingAnimation(true);
+
+ let Get = () => {
+ try {
+ getMedusa(options, 'series').then(shows => {
+ shows.map(show => {
+ __caught.imdb.push(show.id.imdb)
+ __caught.tvdb.push(show.id.tvdb);
+ });
+ });
+
+ getMedusa(options, 'config').then(configuration => {
+ LoadingAnimation();
+ if(!configuration) return new Notification('error', 'Failed to get Medusa configuration');
+
+ let { qualities } = configuration.consts,
+ profileType = $('[data-option="medusaQualityProfileType"i]').selectedIndex,
+ profiles = (profileType == 0? 'presets': profileType == 1? 'values': 'anySets');
+
+ profiles = qualities[profiles];
+
+ teststatus.textContent = '!';
+ teststatus.classList = enabled.checked = !!profiles.length;
+ inusestatus.map(e => e.setAttribute('in-use', enabled.checked));
+
+ if(!profiles.length)
+ return teststatus.title = 'Failed to communicate with Medusa';
+ enabled.parentElement.removeAttribute('disabled');
+
+ profiles.forEach(profile => {
+ let option = document.createElement('option');
+ let { value, name } = profile;
+
+ option.value = value;
+ option.textContent = name;
+ quality.appendChild(option);
+ });
+
+ $('[data-option="medusaQualities"i]').value = JSON.stringify(profiles);
+
+ // Because the was reset, the original value is lost.
+ if(QualityProfileID)
+ $('[data-option="__medusaQuality"i]').value = quality.value = QualityProfileID;
+ });
+
+ let StoragePaths = [];
+ getMedusa(options, 'config').then(configuration => {
+ let storagepaths = configuration.main.rootDirs.filter(d => d.length > 1);
+
+ if(storagepaths.length < 1) return new Notification('error', 'Medusa has no usable storage paths');
+
+ storagepaths.forEach(path => {
+ let option = document.createElement('option');
+
+ StoragePaths.push((option.value = option.textContent = path).replace(/\\/g, '/').replace(/\/+$/, ''));
+ storagepath.appendChild(option);
+ });
+
+ $('[data-option="medusaStoragePaths"i]').value = JSON.stringify(storagepaths.map(path => ({ path, id: path })));
+
+ // Because the was reset, the original value is lost.
+ if(StoragePath) {
+ $('[data-option="__medusaStoragePath"i]').value = StoragePath;
+ storagepath.selectedIndex = StoragePaths.indexOf(StoragePath.replace(/\\/g, '/').replace(/\/+$/, ''));
+ }
+ });
+ } catch(error) {
+ LoadingAnimation();
+
+ throw error;
+ }
+ };
+
+ if(refreshing)
+ Get();
+ else if(url && url.length)
+ requestURLPermissions(url + '/*', allowed =>
+ (allowed)?
+ Get():
+ new Notification('error', 'The user refused permission to access Medusa')
+ );
+}
+
+function getSickBeard(options, api = 'sb') {
+ if(!options.sickBeardToken)
+ return new Notification('error', 'Invalid Sick Beard token');
+
+ let headers = {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ 'X-Api-Key': options.sickBeardToken, // not really used, but just in case...
+ };
+
+ if(options.sickBeardBasicAuthUsername)
+ headers.Authorization = `Basic ${ btoa(`${ options.sickBeardBasicAuthUsername }:${ options.sickBeardBasicAuthPassword }`) }`;
+
+ return fetch(`${ options.sickBeardURLRoot }/api/${ options.sickBeardToken }/?cmd=${ api }`, { headers })
+ .then(response => response.json())
+ .catch(error => {
+ return new Notification('error', 'Sick Beard failed to connect with error:' + String(error)),
+ [];
+ });
+}
+
+function performSickBeardTest({ QualityProfileID, StoragePath, refreshing = false, event }) {
+ let options = getOptionValues(),
+ teststatus = $('#sickBeard_test_status'),
+ path = $('[data-option="sickBeardURLRoot"]'),
+ storagepath = __sickBeard_storagePath__,
+ quality = __sickBeard_qualityProfile__,
+ url,
+ enabled = refreshing? $('#using-sickBeard'): $$('#using-sickBeard'),
+ inusestatus = [...$('[using="sickbeard"]', true)];
+
+ quality.innerHTML = '';
+ teststatus.textContent = '?';
+ storagepath.textContent = '';
+ options.sickBeardURLRoot = url = path.value = options.sickBeardURLRoot.replace(/^(\:\d+)/, 'localhost$1').replace(/^(?!^http(s)?:)/, 'http$1://').replace(/\/+$/, '');
+ inusestatus.map(e => e.setAttribute('in-use', false));
+ LoadingAnimation(true);
+
+ let Get = () => {
+ try {
+ getSickBeard(options, 'shows').then(shows => {
+ let _shows = shows.data;
+
+ shows = [];
+
+ for(let _show in _shows)
+ shows.push(_shows[_show]);
+
+ shows.map(show => {
+ __caught.tvdb.push(show.tvdbid);
+ });
+ });
+
+ getSickBeard(options, 'sb.getdefaults').then(configuration => {
+ LoadingAnimation();
+ if(!configuration) return new Notification('error', 'Failed to get Sick Beard configuration');
+
+ let qualities = configuration.data,
+ profileType = $('[data-option="sickBeardQualityProfileType"i]').selectedIndex,
+ profiles = (profileType == 0? 'initial': 'archive');
+
+ profiles = qualities[profiles];
+
+ teststatus.textContent = '!';
+ teststatus.classList = enabled.checked = !!profiles.length;
+ inusestatus = [...$('[using="sickbeard"]', true)];
+
+ if(!profiles.length)
+ return teststatus.title = 'Failed to communicate with Sick Beard';
+ enabled.parentElement.removeAttribute('disabled');
+
+ profiles = profiles.map((profile, index, array) => {
+ let option = document.createElement('option');
+ let name = profile;
+
+ option.value = option.textContent = name;
+ quality.appendChild(option);
+
+ return { id: name, name };
+ });
+
+ $('[data-option="sickBeardQualities"i]').value = JSON.stringify(profiles);
+
+ // Because the was reset, the original value is lost.
+ if(QualityProfileID)
+ $('[data-option="__sickBeardQuality"i]').value = quality.value = QualityProfileID;
+ });
+
+ let StoragePaths = [];
+ getSickBeard(options, 'sb.getrootdirs').then(configuration => {
+ let storagepaths = configuration.data.filter(d => +d.valid > 0);
+
+ if(storagepaths.length < 1) return new Notification('error', 'Sick Beard has no usable storage paths');
+
+ storagepaths = storagepaths.map(path => {
+ let option = document.createElement('option');
+
+ StoragePaths.push((path = option.value = option.textContent = path.location).replace(/\\/g, '/').replace(/\/+$/, ''));
+ storagepath.appendChild(option);
+
+ return path;
+ });
+
+ $('[data-option="sickBeardStoragePaths"i]').value = JSON.stringify(storagepaths.map((path, index, array) => ({ path, id: index })));
+
+ // Because the was reset, the original value is lost.
+ if(StoragePath) {
+ $('[data-option="__sickBeardStoragePath"i]').value =
+ storagepath.selectedIndex = StoragePaths.indexOf(StoragePath.replace(/\\/g, '/').replace(/\/+$/, ''));
+ }
+ });
+ } catch(error) {
+ LoadingAnimation();
+
+ throw error;
+ }
+ };
+
+ if(refreshing)
+ Get();
+ else if(url && url.length)
+ requestURLPermissions(url + '/*', allowed =>
+ (allowed)?
+ Get():
+ new Notification('error', 'The user refused permission to access Sick Beard')
+ );
+}
+
+function enableCouchPotato() {
+ let inusestatus = [...$('[using="couchpotato"]', true)];
+
+ inusestatus.map(e => e.setAttribute('in-use', true));
+ $('#use-couchpotato').parentElement.removeAttribute('disabled');
+}
+
+function HandleProxySettings(data) {
+ let { UseProxy, ProxyURL, ProxyHeaders } = data,
+ R = RegExp;
+
+ /* "All" secure URI schemes */
+ if(UseProxy && ProxyURL && !/^(aaas|https|msrps|sftp|smtp|shttp|sips|ssh|wss)\:\/\//i.test(ProxyURL))
+ throw new Notification('error', `Insecure URI scheme '${ R.$1 }' detected. Please use a secure scheme.`);
+
+ return {
+ enabled: UseProxy,
+ url: ProxyURL,
+ headers: ProxyHeaders,
+ };
+}
+
+function saveOptions() {
+ ServerID = [...__servers__.selectedOptions][0];
+
+ if(!ServerID || !ServerID.value) {
+ let withoutplex = confirm('Continue without a Plex server?');
+
+ if(withoutplex)
+ return saveOptionsWithoutPlex();
+ else
+ return new Notification('error', 'Select a server!');
+ }
+ ServerID = ServerID.value;
+
+ let server = PlexServers.find(ID => ID.clientIdentifier === ServerID);
+
+ // This should never happen, but can be useful for debugging.
+ if(!server)
+ return new Notification('error', `Could not find Plex server ${ ServerID }`),
+ null;
+
+ terminal.log('Selected server information:', server);
+
+ // Important detail: we get the token from the selected server, NOT the token the user has entered before.
+ let serverToken = server.accessToken,
+ serverConnections = getPlexConnections(server);
+ ClientID = server.clientIdentifier;
+
+ if(!serverConnections.length)
+ return new Notification('error', 'Could not locate Plex server URL'),
+ null;
+ terminal.log('Plex Server connections:', serverConnections);
+
+ // With a "user token" you can access multiple servers. A "normal" token is just for one server.
+ let options = getOptionValues(),
+ endingSlash = ($0, $1, $$, $_) => ($1 + (/\\/.test($_)? '\\': '/'));
+
+ options.IGNORE_PLEX = false;
+
+ let r, R = 'Radarr',
+ s, S = 'Sonarr',
+ w, W = 'Watcher',
+ c, C = 'CouchPotato',
+ o, O = 'Ombi',
+ m, M = 'Medusa',
+ i, I = 'Sick Beard';
+
+ let who = () => (r? R: s? S: w? W: c? C: o? O: m? M: i? I: 'manager');
+
+ // Instead of having the user be so wordy, complete the URL ourselves here
+ if((r = !options.radarrURLRoot && options.radarrToken) || (s = !options.sonarrURLRoot && options.sonarrToken) || (w = !options.watcherURLRoot && options.watcherToken) || (o = !options.ombiURLRoot && options.ombiToken) || (m = !options.medusaURLRoot && options.medusaToken) || (i = !options.sickBeardURLRoot && options.sickBeardToken)) {
+ return new Notification('error', `Please enter a valid URL for ${ who() }`),
+ null;
+ } if((r = options.radarrURLRoot && !options.radarrStoragePath) || (s = options.sonarrURLRoot && !options.sonarrStoragePath) || (m = options.medusaURLRoot && !options.medusaStoragePath) || (i = options.sickBeardURLRoot && !options.sickBeardStoragePath)) {
+ return new Notification('error', `Please enter a valid storage path for ${ who() }`),
+ null;
+ } if(options.watcherURLRoot && !options.watcherQualityProfileId) {
+ return new Notification('error', 'Select a quality profile for Watcher'),
+ null;
+ } if(options.radarrURLRoot && !options.radarrQualityProfileId) {
+ return new Notification('error', 'Select a quality profile for Radarr'),
+ null;
+ } if(options.sonarrURLRoot && !options.sonarrQualityProfileId) {
+ return new Notification('error', 'Select a quality profile for Sonarr'),
+ null;
+ } if(options.medusaURLRoot && !options.medusaQualityProfileId) {
+ return new Notification('error', 'Select a quality profile for Medusa'),
+ null;
+ } if(options.sickBeardURLRoot && !options.sickBeardQualityProfileId) {
+ return new Notification('error', 'Select a quality profile for Sick Beard'),
+ null;
+ } if(!ClientID) {
+ ClientID = window.crypto.getRandomValues(new Uint32Array(5))
+ .join('-');
+ }
+ storage.set({ ClientID });
+
+ options.plexURL = options.plexURLRoot = (options.plexURL || "https://app.plex.tv/")
+ .replace(/^(\:\d+)/, 'localhost$1')
+ .replace(/([^\\\/])$/, endingSlash)
+ .replace(/^(?!^http(s)?:\/\/)(.+)/, 'http$1://$2');
+
+ options.ombiURLRoot = (options.ombiURLRoot || "")
+ .replace(/([^\\\/])$/, endingSlash)
+ .replace(/^(?!^http(s)?:\/\/)(.+)/, 'http$1://$2');
+
+ options.medusaURLRoot = (options.medusaURLRoot || "")
+ .replace(/([^\\\/])$/, endingSlash)
+ .replace(/^(?!^http(s)?:\/\/)(.+)/, 'http$1://$2');
+
+ options.watcherURLRoot = (options.watcherURLRoot || "")
+ .replace(/([^\\\/])$/, endingSlash)
+ .replace(/^(?!^http(s)?:\/\/)(.+)/, 'http$1://$2');
+
+ options.radarrURLRoot = (options.radarrURLRoot || "")
+ .replace(/([^\\\/])$/, endingSlash)
+ .replace(/^(?!^http(s)?:\/\/)(.+)/, 'http$1://$2');
+
+ options.sonarrURLRoot = (options.sonarrURLRoot || "")
+ .replace(/([^\\\/])$/, endingSlash)
+ .replace(/^(?!^http(s)?:\/\/)(.+)/, 'http$1://$2');
+
+ options.sickBeardURLRoot = (options.sickBeardURLRoot || "")
+ .replace(/([^\\\/])$/, endingSlash)
+ .replace(/^(?!^http(s)?:\/\/)(.+)/, 'http$1://$2');
+
+ options.radarrStoragePath = options.radarrStoragePath
+ .replace(/([^\\\/])$/, endingSlash);
+
+ options.sonarrStoragePath = options.sonarrStoragePath
+ .replace(/([^\\\/])$/, endingSlash);
+
+ options.medusaStoragePath = options.medusaStoragePath
+ .replace(/([^\\\/])$/, endingSlash);
+
+ options.sickBeardStoragePath = options.sickBeardStoragePath
+ .replace(/([^\\\/])$/, endingSlash);
+
+ // icons for the popup page
+ for(let index = 0, array = 'plex ombi medusa watcher radarr sonarr couchpotato sickBeard'.split(' '), item = save('URLs', array); index < array.length; index++)
+ save(`${ item = array[index] }.url`, options[`${ item }URLRoot`]);
+
+ // Dynamically asking permissions
+ requestURLPermissions(options.couchpotatoURLRoot);
+ requestURLPermissions(options.watcherURLRoot);
+ requestURLPermissions(options.radarrURLRoot);
+ requestURLPermissions(options.sonarrURLRoot);
+ requestURLPermissions(options.medusaURLRoot);
+ requestURLPermissions(options.ombiURLRoot);
+ requestURLPermissions(options.sickBeardURLRoot);
+
+ // Handle the proxy settings
+ options.proxy = HandleProxySettings(options);
+
+ function OptionsSavedMessage() {
+ // Update status to let the user know the options were saved
+ new Notification('update', 'Saved', 1500);
+ }
+ new Notification('update', 'Saving...', 1500);
+ LoadingAnimation(true);
+
+ let data = {
+ ...options,
+ servers: [
+ {
+ id: ClientID,
+ token: serverToken,
+ connections: serverConnections
+ },
+ ]
+ };
+
+ storage.set(data, () => {
+ LoadingAnimation();
+
+ if(chrome.runtime.lastError) {
+ new Notification('error', 'Error with saving: ' + chrome.runtime.lastError.message);
+ storage.set(data, OptionsSavedMessage);
+ } else {
+ terminal.log('Saved Options: ', options);
+ OptionsSavedMessage();
+ }
+
+ chrome.runtime.sendMessage({
+ type: 'UPDATE_CONFIGURATION',
+ options,
+ }, response => {
+ if(response === undefined) {
+ console.warn(`Update response (UPDATE_CONFIGURATION): Invalid response...`, { response, options });
+ } else {
+ console.log(`Update response (UPDATE_CONFIGURATION):`, { response, options });
+ }
+ });
+ });
+}
+
+function saveOptionsWithoutPlex() {
+ // See #4
+ let options = getOptionValues(),
+ endingSlash = ($0, $1, $$, $_) => ($1 + (/\\/.test($_)? '\\': '/'));
+
+ options.IGNORE_PLEX = true;
+
+ let r, R = 'Radarr',
+ s, S = 'Sonarr',
+ w, W = 'Watcher',
+ c, C = 'CouchPotato',
+ o, O = 'Ombi',
+ m, M = 'Medusa',
+ i, I = 'Sick Beard';
+
+ let who = () => (r? R: s? S: w? W: c? C: o? O: m? M: i? I: 'manager');
+
+ // Instead of having the user be so wordy, complete the URL ourselves here
+ if((r = !options.radarrURLRoot && options.radarrToken) || (s = !options.sonarrURLRoot && options.sonarrToken) || (w = !options.watcherURLRoot && options.watcherToken) || (o = !options.ombiURLRoot && options.ombiToken) || (m = !options.medusaURLRoot && options.medusaToken) || (i = !options.sickBeardURLRoot && options.sickBeardToken)) {
+ return new Notification('error', `Please enter a valid URL for ${ who() }`),
+ null;
+ } if((r = options.radarrURLRoot && !options.radarrStoragePath) || (s = options.sonarrURLRoot && !options.sonarrStoragePath) || (m = options.medusaURLRoot && !options.medusaStoragePath) || (i = options.sickBeardURLRoot && !options.sickBeardStoragePath)) {
+ return new Notification('error', `Please enter a valid storage path for ${ who() }`),
+ null;
+ } if(options.watcherURLRoot && !options.watcherQualityProfileId) {
+ return new Notification('error', 'Select a quality profile for Watcher'),
+ null;
+ } if(options.radarrURLRoot && !options.radarrQualityProfileId) {
+ return new Notification('error', 'Select a quality profile for Radarr'),
+ null;
+ } if(options.sonarrURLRoot && !options.sonarrQualityProfileId) {
+ return new Notification('error', 'Select a quality profile for Sonarr'),
+ null;
+ } if(options.medusaURLRoot && !options.medusaQualityProfileId) {
+ return new Notification('error', 'Select a quality profile for Medusa'),
+ null;
+ } if(options.sickBeardURLRoot && !options.sickBeardQualityProfileId) {
+ return new Notification('error', 'Select a quality profile for Sick Beard'),
+ null;
+ } if(!ClientID) {
+ ClientID = 'web-to-plex:client';
+ storage.set({ ClientID });
+ }
+
+ // Still need to set this
+ options.plexURL = options.plexURLRoot = "https://ephellon.github.io/web.to.plex/no.server/";
+
+ options.ombiURLRoot = (options.ombiURLRoot || "")
+ .replace(/([^\\\/])$/, endingSlash)
+ .replace(/^(?!^http(s)?:\/\/)(.+)/, 'http$1://$2');
+
+ options.medusaURLRoot = (options.medusaURLRoot || "")
+ .replace(/([^\\\/])$/, endingSlash)
+ .replace(/^(?!^http(s)?:\/\/)(.+)/, 'http$1://$2');
+
+ options.watcherURLRoot = (options.watcherURLRoot || "")
+ .replace(/([^\\\/])$/, endingSlash)
+ .replace(/^(?!^http(s)?:\/\/)(.+)/, 'http$1://$2');
+
+ options.radarrURLRoot = (options.radarrURLRoot || "")
+ .replace(/([^\\\/])$/, endingSlash)
+ .replace(/^(?!^http(s)?:\/\/)(.+)/, 'http$1://$2');
+
+ options.sonarrURLRoot = (options.sonarrURLRoot || "")
+ .replace(/([^\\\/])$/, endingSlash)
+ .replace(/^(?!^http(s)?:\/\/)(.+)/, 'http$1://$2');
+
+ options.sickBeardURLRoot = (options.sickBeardURLRoot || "")
+ .replace(/([^\\\/])$/, endingSlash)
+ .replace(/^(?!^http(s)?:\/\/)(.+)/, 'http$1://$2');
+
+ options.radarrStoragePath = options.radarrStoragePath
+ .replace(/([^\\\/])$/, endingSlash);
+
+ options.sonarrStoragePath = options.sonarrStoragePath
+ .replace(/([^\\\/])$/, endingSlash);
+
+ options.medusaStoragePath = options.medusaStoragePath
+ .replace(/([^\\\/])$/, endingSlash);
+
+ options.sickBeardStoragePath = options.sickBeardStoragePath
+ .replace(/([^\\\/])$/, endingSlash);
+
+ // icons for the popup page
+ for(let index = 0, array = 'ombi medusa watcher radarr sonarr couchpotato sickBeard'.split(' '), item = save('URLs', array); index < array.length; index++)
+ save(`${ item = array[index] }.url`, options[`${ item }URLRoot`]);
+
+ // Dynamically asking permissions
+ requestURLPermissions(options.couchpotatoURLRoot);
+ requestURLPermissions(options.watcherURLRoot);
+ requestURLPermissions(options.radarrURLRoot);
+ requestURLPermissions(options.sonarrURLRoot);
+ requestURLPermissions(options.medusaURLRoot);
+ requestURLPermissions(options.ombiURLRoot);
+ requestURLPermissions(options.sickBeardURLRoot);
+
+ // Handle the proxy settings
+ options.proxy = HandleProxySettings(options);
+
+ function OptionsSavedMessage() {
+ // Update status to let the user know the options were saved
+ new Notification('update', 'Saved', 1500);
+ }
+ new Notification('update', 'Saving...', 1500);
+
+ let data = options;
+
+ LoadingAnimation(true);
+
+ storage.set(data, () => {
+ LoadingAnimation();
+
+ if(chrome.runtime.lastError) {
+ new Notification('error', 'Error with saving: ' + chrome.runtime.lastError.message);
+ storage.set(data, OptionsSavedMessage);
+ } else {
+ terminal.log('Saved Options: ', options);
+ OptionsSavedMessage();
+ }
+
+ chrome.runtime.sendMessage({
+ type: 'UPDATE_CONFIGURATION',
+ options,
+ }, response => {
+ if(response === undefined) {
+ console.warn(`Update response (UPDATE_CONFIGURATION): Invalid response...`, { response, options });
+ } else {
+ console.log(`Update response (UPDATE_CONFIGURATION):`, { response, options });
+ }
+ });
+ });
+}
+
+function requestURLPermissions(url, callback) {
+ if(url && callback)
+ return callback(true);
+ else if(url)
+ return true;
+ else
+ return false;
+
+ /* DEAD CODE - DEAD CODE - DEAD CODE - DEAD CODE - DEAD CODE */
+ /* DEAD CODE - DEAD CODE - DEAD CODE - DEAD CODE - DEAD CODE */
+ /* DEAD CODE - DEAD CODE - BANANA 🍌 - DEAD CODE - DEAD CODE */
+ /* DEAD CODE - DEAD CODE - DEAD CODE - DEAD CODE - DEAD CODE */
+ /* DEAD CODE - DEAD CODE - DEAD CODE - DEAD CODE - DEAD CODE */
+
+ /* Obsolete, but may be useful later? */
+ if(!url || /^https?\:\/\/\*/i.test(url))
+ return;
+
+ // TODO: Firefox doesn't have support for the chrome.permissions API.
+ if(chrome.permissions) {
+ // When asking permissions the URL needs to have a trailing slash.
+ chrome.permissions.request({ origins: [`${ url }`] }, callback);
+ }
+}
+
+// Restores select box and checkbox state using the preferences
+// stored in chrome.storage.*
+function restoreOptions(OPTIONS) {
+ function setOptions(items) {
+ if(!items) return;
+
+ __options__.forEach(option => {
+ let el = $(`[data-option="${ option }"]`);
+
+ if(!el) return;
+
+ if(el.type == 'checkbox')
+ el.checked = items[option];
+ else
+ el.value = items[option] || '';
+
+ if(el.value !== '' && !el.disabled) {
+ if(el.type == 'checkbox')
+ el.setAttribute('save', el.checked);
+ else if(el.type == 'range')
+ el.setAttribute('save', el.value),
+ el.oninput({ target: el });
+ else if(/password$/i.test(option))
+ el.setAttribute('type', el.type = 'password');
+ else
+ el.placeholder = `Last save: ${ el.value }`,
+ el.title = `Double-click to restore value ("${ el.value }")`,
+ el.setAttribute('save', el.value),
+ el.ondblclick = event => el.value = el.getAttribute('save');
+ }
+ });
+
+ let refreshing = true;
+
+ if(items.plexToken)
+ performPlexTest({ ServerID: items.servers? items.servers[0].id: null });
+ if(items.watcherURLRoot)
+ performWatcherTest({ QualityProfileID: items.watcherQualityProfileId, refreshing });
+ if(items.ombiURLRoot)
+ performOmbiTest({ refreshing });
+ if(items.medusaURLRoot)
+ performMedusaTest({ QualityProfileID: items.medusaQualityProfileId, StoragePath: items.medusaStoragePath, refreshing });
+ if(items.radarrURLRoot)
+ performRadarrTest({ QualityProfileID: items.radarrQualityProfileId, StoragePath: items.radarrStoragePath, refreshing });
+ if(items.sonarrURLRoot)
+ performSonarrTest({ QualityProfileID: items.sonarrQualityProfileId, StoragePath: items.sonarrStoragePath, refreshing });
+ if(items.sickBeardURLRoot)
+ performSickBeardTest({ QualityProfileID: items.sickBeardQualityProfileId, StoragePath: items.sickBeardStoragePath, refreshing });
+ if(items.couchpotatoURLRoot)
+ enableCouchPotato();
+
+ let __domains = (sites => {
+ let array = [];
+
+ for(let site in sites)
+ array.push(site);
+
+ return array;
+ })({ ...builtin_sites, ...plugin_sites });
+
+ $('[data-option="__domains"i]').value = __domains;
+ }
+
+ if (OPTIONS && typeof OPTIONS == 'string') {
+ OPTIONS = JSON.parse(OPTIONS);
+
+ setOptions(OPTIONS);
+ } else {
+ storage.get(null, items => {
+ // Sigh... This is a workaround for Firefox; newer versions have support for the `chrome.storage.sync` API,
+ // BUT, it will throw an error if you haven't enabled it...
+ if(chrome.runtime.lastError)
+ storage.get(null, setOptions);
+ else
+ setOptions(items);
+ });
+ }
+
+ setTimeout(() => {
+ $('.checkbox:not([disabled]) input:not([disabled])', true)
+ .forEach((element, index, array) => {
+ let options = getOptionValues(),
+ name = element.dataset.option;
+
+ if(!name || options[name] === undefined || options[name] === null)
+ return;
+
+ element.checked = options[name];
+ })
+ }, 250);
+}
+
+// Helpers
+document.furnish = function furnish(name, attributes = {}, ...children) {
+ let u = v => v && v.length, R = RegExp;
+
+ if( !u(name) )
+ throw TypeError(`TAGNAME cannot be ${ (name === '')? 'empty': name }`);
+
+ let options = attributes.is === true? { is: true }: null;
+
+ delete attributes.is;
+
+ name = name.split(/([#\.][^#\.\[\]]+)/).filter( u );
+
+ if(name.length <= 1)
+ name = name[0].split(/^([^\[\]]+)(\[.+\])/).filter( u );
+
+ if(name.length > 1)
+ for(let n = name, i = 1, l = n.length, t, v; i < l; i++)
+ if((v = n[i].slice(1, n[i].length)) && (t = n[i][0]) == '#')
+ attributes.id = v;
+ else if(t == '.')
+ attributes.classList = [].slice.call(attributes.classList || []).concat(v);
+ else if(/\[(.+)\]/.test(n[i]))
+ R.$1.split('][').forEach(N => attributes[(N = N.split('=', 2))[0]] = N[1] || '');
+ name = name[0];
+
+ let element = document.createElement(name, options);
+
+ if(attributes.classList instanceof Array)
+ attributes.classList = attributes.classList.join(' ');
+
+ Object.entries(attributes).forEach(
+ ([name, value]) => (/^(on|(?:inner|outer)(?:HTML|Text)|textContent|class(?:List|Name)$|value)/.test(name))?
+ element[name] = value:
+ element.setAttribute(name, value)
+ );
+
+ children
+ .filter( child => child !== undefined && child !== null )
+ .forEach(
+ child =>
+ child instanceof Element?
+ element.append(child):
+ child instanceof Node?
+ element.appendChild(child):
+ element.appendChild(document.createTextNode(child))
+ );
+
+ return element;
+};
+
+// Default sites and their links
+let builtins = {
+ "Netflix": "https://netflix.com/",
+ "Verizon": "https://tv.verizon.com/",
+ "Trakt": "https://trakt.tv/",
+ "Shana Project": "https://shanaproject.com/",
+ "YouTube": "https://youtube.com/",
+ "Rotten Tomatoes": "https://rottentomatoes.com/",
+ "Fandango": "https://www.fandango.com/",
+ "Amazon": "https://www.amazon.com/Amazon-Video/s/browse/ref=web_to_plex?node=2858778011",
+ "IMDb": "https://imdb.com/",
+ "Couch Potato": "http://couchpotato.life/",
+ "VRV": "https://vrv.co/",
+ "TMDb": "https://themoviedb.org/",
+ "Letterboxd": "https://letterboxd.com/",
+ "Hulu": "https://hulu.com/",
+ "Flickmetrix": "https://flickmetrix.com/",
+ "TVDb": "https://thetvdb.com/",
+ "Metacritic": "https://www.metacritic.com/",
+ "ShowRSS": "https://showrss.info/",
+ "Vudu": "https://vudu.com/",
+ "Movieo": "https://movieo.me/",
+ "Vumoo": "https://vumoo.to/",
+ "TV Maze": "https://tvmaze.com/",
+ "Google Play": "https://play.google.com/store/movies",
+ "Google": "https://google.com/",
+ "iTunes": "https://itunes.apple.com/",
+ "JustWatch": "https://justwatch.com/",
+ "MovieMeter": "https://moviemeter.nl/",
+ "GoStream": "https://gostream.site/",
+ "Tubi": "https://tubitv.com/",
+ "Web to Plex": "https://ephellon.github.io/web.to.plex/",
+ "Allocine": "https://allocine.fr/",
+ "Plex": "https://app.plex.tv/",
+
+ // Dont' forget to add to the __options__ array!
+}, builtin_array = [], builtin_sites = {}, builtinElement = $('#builtin');
+
+for(let builtin in builtins)
+ builtin_array.push(builtin);
+builtin_array = builtin_array.sort((a,b) => { let [x, y] = [a, b].map(v => v.toLowerCase()); return x < y? -1: 1; });
+
+for(let index = 0, length = builtin_array.length; builtinElement && index < length; index++) {
+ let builtin = builtins[builtin_array[index]];
+
+ if(builtin instanceof Array) {
+ for(let i = 0, l = builtin.length; i < l; i++) {
+ let title = builtin_array[index],
+ name = 'builtin_' + title.toLowerCase().replace(/\s+/g, ''),
+ url = new URL(builtin[i]),
+ js = name.replace(/^builtin_/i, ''),
+ o = url.origin,
+ r = TLDHost(url.host);
+
+ builtin_sites[r] = o;
+
+ if(!i)
+ builtinElement.innerHTML +=
+`
+${ title }
+
+
+
+
+
+
+
+`;
+ }
+ } else {
+ let title = builtin_array[index],
+ name = 'builtin_' + title.toLowerCase().replace(/\s+/g, ''),
+ url = new URL(builtins[title]),
+ js = name.replace(/^builtin_/i, ''),
+ o = url.origin,
+ r = TLDHost(url.host);
+
+ builtin_sites[r] = o;
+
+ builtinElement.innerHTML +=
+`
+${ title }
+
+
+
+
+
+
+
+`;
+ }
+
+ // save(`permission:${ r }`, true);
+ // save(`script:${ r }`, js);
+ // save(`builtin:${ r }`, true);
+}
+
+save('builtin.sites', builtin_sites);
+
+$('[id^="builtin_"]', true)
+ .forEach(element => addListener(element, 'click', event => {
+ let self = traverse(event.target, element => /^builtin_/.test(element.id), true),
+ bid = self.getAttribute('bid'),
+ js = self.getAttribute('js');
+
+ if(self.checked) {
+ terminal.log(bid, builtin_sites[bid]);
+ requestURLPermissions(builtin_sites[bid].replace(/https?:\/\/(ww\w+\.)?/i, '*://*.').replace(/\/?$/, '/*'), granted => {
+ save(`permission:${ bid }`, granted);
+ save(`script:${ bid }`, granted? js: null);
+ });
+ } else {
+ save(`permission:${ bid }`, false);
+ save(`script:${ bid }`, null);
+ }
+
+ Recall.CountEnabledSites();
+
+ save(`builtin:${ bid }`, true);
+ })
+);
+
+addListener($('#all-builtin'), 'click', event => {
+ let self = traverse(event.target, element => element == $('#all-builtin'), true),
+ checked = self.checked;
+
+ $(`[id^="builtin"]${ checked? ':not(:checked)': ':checked' }`, true)
+ .forEach(element => element.checked = checked);
+
+ Recall.CountEnabledSites();
+});
+
+// Plugins and their links
+let plugins = {
+ 'Indomovie': ['https://indomovietv.club/', 'https://indomovietv.org/', 'https://indomovietv.net/'],
+ 'Toloka': 'https://toloka.to/',
+ 'Shana Project': 'https://www.shanaproject.com/',
+ 'My Anime List': 'https://myanimelist.net/',
+ 'My Shows': 'https://myshows.me/',
+ 'Redbox': 'https://www.redbox.com/',
+ 'Kitsu': 'https://kitsu.io/',
+ 'Go': 'https://freeform.go.com/',
+ 'SnagFilms': 'http://snagfilms.com/',
+ 'Free Movies Cinema': 'https://freemoviescinema.com/',
+ 'Fox Searchlight': 'http://foxsearchlight.com/',
+
+ // Don't forget to add to the __options__ array!
+}, plugin_array = [], plugin_sites = {}, pluginElement = $('#plugins');
+
+for(let plugin in plugins)
+ plugin_array.push(plugin);
+plugin_array = plugin_array.sort((a,b) => { let [x, y] = [a, b].map(v => v.toLowerCase()); return x < y? -1: 1; });
+
+for(let index = 0, length = plugin_array.length; pluginElement && index < length; index++) {
+ let plugin = plugins[plugin_array[index]];
+
+ if(plugin instanceof Array) {
+ for(let i = 0, l = plugin.length; i < l; i++) {
+ let title = plugin_array[index],
+ name = 'plugin_' + title.toLowerCase().replace(/\s+/g, ''),
+ url = new URL(plugin[i]),
+ js = name.replace(/^plugin_/i, ''),
+ o = url.origin,
+ r = TLDHost(url.host);
+
+ plugin_sites[r] = o;
+
+ if(!i)
+ pluginElement.innerHTML +=
+`
+${ title }
+
+
+
+
+
+
+
+`;
+ }
+ } else {
+ let title = plugin_array[index],
+ name = 'plugin_' + title.toLowerCase().replace(/\s+/g, ''),
+ url = new URL(plugins[title]),
+ js = name.replace(/^plugin_/i, ''),
+ o = url.origin,
+ r = TLDHost(url.host);
+
+ plugin_sites[r] = o;
+
+ pluginElement.innerHTML +=
+`
+${ title }
+
+
+
+
+
+
+
+`;
+ }
+}
+
+save('optional.sites', plugin_sites);
+
+$('[id^="plugin_"]', true)
+ .forEach(element => addListener(element, 'click', event => {
+ let self = traverse(event.target, element => /^plugin_/.test(element.id), true),
+ pid = self.getAttribute('pid'),
+ js = self.getAttribute('js');
+
+ if(self.checked) {
+ terminal.log(pid, plugin_sites[pid]);
+ requestURLPermissions(plugin_sites[pid].replace(/https?:\/\/(ww\w+\.)?/i, '*://*.').replace(/\/?$/, '/*'), granted => {
+ save(`permission:${ pid }`, granted);
+ save(`script:${ pid }`, granted? js: null);
+ });
+ } else {
+ save(`permission:${ pid }`, false);
+ save(`script:${ pid }`, null);
+ }
+
+ Recall.CountEnabledSites();
+
+ save(`builtin:${ pid }`, false);
+ })
+);
+
+addListener($('#all-plugin'), 'click', event => {
+ let self = traverse(event.target, element => element == $('#all-plugin'), true),
+ checked = self.checked;
+
+ $(`[id^="plugin"]${ checked? ':not(:checked)': ':checked' }`, true)
+ .forEach(element => element.checked = checked);
+
+ Recall.CountEnabledSites();
+});
+
+let empty = () => {};
+
+document.addEventListener('DOMContentLoaded', restoreOptions);
+__save__.addEventListener('click', saveOptions);
+
+addListener($('#plex_test'), 'mouseup', event => {
+ let pt = $('#plex_token').value,
+ pu = $('#plex_username').value,
+ pp = $('#plex_password').value,
+ ou = $('#ombi_url').value,
+ oa = $('#ombi_api').value;
+
+ if(pt)
+ performPlexTest({ ServerID, event });
+ else if(pu && pp)
+ performPlexLogin({ event });
+ else if(ou && oa)
+ performOmbiLogin({ event });
+});
+$('#watcher_test', true).forEach(element => addListener(element, 'mouseup', event => performWatcherTest({ event })));
+$('#radarr_test', true).forEach(element => addListener(element, 'mouseup', event => performRadarrTest({ event })));
+$('#sonarr_test', true).forEach(element => addListener(element, 'mouseup', event => performSonarrTest({ event })));
+$('#medusa_test', true).forEach(element => addListener(element, 'mouseup', event => performMedusaTest({ event })));
+$('#ombi_test', true).forEach(element => addListener(element, 'mouseup', event => performOmbiTest({ event })));
+$('#sickBeard_test', true).forEach(element => addListener(element, 'mouseup', event => performSickBeardTest({ event })));
+$('#enable-couchpotato', true).forEach(element => addListener(element, 'mouseup', event => enableCouchPotato({ event })));
+
+/* INPUT | Get the JSON data */
+addListener($('#json_get'), 'mouseup', event => {
+ let data_container = $('#json_data'),
+ data = atob((data_container.value || data_container.textContent).replace(/\s*\[.+\]\s*/, ''));
+
+ if(!data) return new Notification('warning', 'The data cannot be blank, null, or undefined');
+
+ try {
+ restoreOptions(data);
+
+ new Notification('update', 'Restored configuration data', 3000);
+ } catch(error) {
+ new Notification('error', `Error restoring configuration data: ${ error }`);
+ }
+});
+
+/* OUTPUT | Set the JSON data */
+addListener($('#json_set'), 'mouseup', event => {
+ let data_container = $('#json_data'),
+ data = getOptionValues();
+
+ data_container.value = data_container.textContent = `[${ (new Date).toString().slice(0, 24) }]${ btoa(JSON.stringify(data)) }`;
+
+ new Notification('info', 'Copy the configuration data somewhere safe, use it to restore your options');
+});
+
+/* Erase Cached Searches */
+addListener($('#erase_cache'), 'mouseup', event => {
+ let options = JSON.stringify(getOptionValues());
+
+ new Notification('info', 'Clearing...', 3000);
+ storage.get(null, items => {
+ for(let item in items)
+ if(/^~\/cache\//i.test(item))
+ storage.remove(item);
+ });
+
+ saveOptions();
+});
+
+$('[type="range"]', true)
+ .forEach((element, index, array) => {
+ let sibling = element.nextElementSibling,
+ symbol = element.getAttribute('symbol') || '';
+
+ sibling.value = element.value + symbol;
+
+ element.oninput = (event, self) => (self = event.target).nextElementSibling.value = self.value + (self.getAttribute('symbol') || '');
+ });
+
+$('.checkbox', true)
+ .forEach((element, index, array) => {
+ addListener(element, 'mouseup', event => {
+ let self = traverse(path(event), element => (element && element.type == 'checkbox'), true),
+ isChecked = e => e.setAttribute('in-use', !self.checked);
+
+ if('disabled' in self.attributes || traverse(self, element => (element && 'disabled' in element.attributes), true))
+ return event.preventDefault(true);
+ /* Stop the event from further processing */
+
+ /* Handle special checkboxes */
+ switch(self.id.toLowerCase()) {
+ /* Update the database when the option is toggled */
+ case 'use-lzw':
+ if(!self.checked)
+ new Notification('update', 'Compressing data...', 3000, () => new Notification('update', 'Compressed', 3000), false);
+ else
+ new Notification('update', 'Decompressing data...', 3000, () => new Notification('update', 'Decompressed', 3000), false);
+
+ let options = getOptionValues();
+
+ for(let name in options)
+ if(/^__/.test(name)) {
+ if(!self.checked)
+ options[name] = compress(BWT(options[name]));
+ else
+ options[name] = iBWT(decompress(options[name]));
+ }
+ break;
+
+ case 'extension-branch-type':
+ [...$('[counter-for="devmode"]', true)].map(isChecked);
+ Recall.ToggleDeveloprBadge(!self.checked);
+ break;
+
+ default:
+ let R = RegExp;
+
+ if(/^using-([\w\-]+)/.test(self.id))
+ $(`[using="${ R.$1 }"i]`, true).map(isChecked);
+ else if(/^use/i.test(self.dataset.option))
+ $(`[using="${ self.id }"i]`, true).map(isChecked);
+ break;
+ }
+
+ if(/(^theme:|using)/i.test(self.dataset.option))
+ self.checked = !self.checked;
+ });
+ });
+
+$('.test', true)
+ .forEach((element, index, array) => {
+ addListener(element, 'mouseup', async event => {
+ let self = traverse(event.target, element => !!~[...element.classList].indexOf('test'));
+
+ // await saveOptions();
+
+ open(self.getAttribute('href'), self.getAttribute('target'));
+ });
+ });
+
+$('[data-option^="theme:"i], [data-option^="theme:"i] + label', true)
+ .forEach((element, index, array) => {
+ addListener(element, 'mouseup', async event => {
+ let self = traverse(event.target, element => /^theme:/i.test(element.dataset.option), true),
+ R = RegExp;
+
+ let [a, b] = self.getAttribute('theme').split(/\s*:\s*/).filter(v => v),
+ value = `${self.dataset.option.replace(/^theme:/i, '')}-${b}`;
+
+ if(/^(get|read|for)$/i.test(a))
+ __theme.push(`${ value }=${ self.value }`)
+ else if(/^(checkbox)$/i.test(self.type) && (self.checked + '') != a)
+ // backwards; fires late
+ __theme.push(value);
+ else if(/^(text|input|button|\B)$/i.test(self.type) && R(self.value + '', 'i').test(a))
+ __theme.push(value);
+ else
+ __theme = __theme.filter(v => v != value);
+
+ /* Get rid of repeats */
+ // __theme = __theme.join('\u0000').replace(/([\w\-]+\=)([^\u0000]+?)\u0000\1[^\u0000]+?/g, ($0, $1, $2, $$, $_) => $1 + $2);
+ });
+ });
+
+let hold = document.createElement('summary'),
+ /* swap(from, to[, ...new from children]) */
+ swap = (a, b, ...c) => {
+ let d = a.children;
+
+ (c = c.flat(Infinity)).forEach(e => a.insertBefore(e, d[0]));
+
+ for(let f = c.length; d.length > f;)
+ b.appendChild(d[f]);
+ },
+ uuid = e => {
+ let u = [];
+
+ for(let _; e; e = e.parentElement) {
+ _ = e.tagName.toLowerCase();
+
+ if(/^html$/i.test(_))
+ break;
+ if(e.id)
+ _ += '#' + (
+ /\s/.test(e.id)?
+ `[id="${ e.id }"]`:
+ e.id
+ );
+ if(e.className)
+ _ += '.' + e.className.replace(/ /g, '.');
+ if(e.parentElement.querySelector(_) !== e)
+ _ += `:nth-child(${( [...e.parentElement.children].indexOf(e) + 1 )})`;
+ u.push(_);
+ }
+ u.reverse();
+
+ return u.join('>');
+ };
+
+$('.bar > article > details', true)
+ .forEach((element, index, array) => {
+ addListener(element, 'mouseup', event => {
+ let self = path(event).filter(e => /^details$/i.test(e.tagName))[0],
+ head = path(event).filter(e => /\bbar\b/i.test(e.classList))[0].querySelector('header'),
+ open = e => {try {e.setAttribute('open', true);} catch(r) {/* not actually an error */}},
+ disp = $('display');
+
+ if(uuid(self) == disp.value) {
+ return;
+ } else if(disp.value) {
+ swap(disp, $(disp.value));
+ }
+
+ if(!('open' in self.attributes)) {
+ hold.innerHTML = self.querySelector('summary').innerHTML;
+
+ disp.setAttribute('_title_', head.innerText.toCaps());
+ disp.setAttribute('_sub-title_', hold.innerText);
+ disp.value = uuid(self);
+
+ let checked = {};
+
+ [...self.children].forEach(child => {
+ let input = child.querySelector('.checkbox input');
+
+ if(!input)
+ return;
+
+ checked[uuid(child)] = input.checked;
+ });
+
+ swap(self, disp, hold);
+
+ [...self.children].forEach(child => {
+ let input = child.querySelector('.checkbox input');
+
+ if(!input)
+ return;
+
+ input.checked = checked[uuid(child)];
+ });
+
+ [...$('.bar > article > details[open]', true)].forEach(e => e.removeAttribute('open'));
+ open(self);
+ }
+ })
+ });
+
+$('[href^="#!/"]', true)
+ .forEach(element => {
+ let path = element.getAttribute('href').toLowerCase().split('/'),
+ root = path[1],
+ file = path.slice(2, path.length).join('/'),
+ windows = false,
+ apple = false,
+ linux = false,
+ { platform } = navigator;
+
+ if(/^win(\d+|ce|dows)$/i.test(platform))
+ windows = true;
+ else if(/^(i(?:phone|p[ao]d)(?: simulator)?|mac(?:intosh|intel|ppc|68k)|pike)/i.test(platform))
+ apple = true;
+ else if(/^((?:free|open)bsd|linux)/i.test(platform))
+ linux = true;
+
+ let uri = '#';
+
+ switch(root) {
+ // Native URIs
+ case 'native':
+ switch(file) {
+ case 'settings/network/proxy':
+ uri =
+ windows?
+ 'ms-settings:network-proxy':
+ apple?
+ '#':
+ linux?
+ '#':
+ '#';
+ break;
+
+ default:
+ uri = '#';
+ break;
+ }
+ break;
+
+ // Other URIs
+ // ...
+ }
+
+ element.setAttribute('href', `#!/NaCl+${ btoa(uri).replace(/=+$/, '') }`);
+ element.onclick = event => {
+ let self = traverse(event.target, element => /^#!\/NaCl\+/.test(element.getAttribute('href'))),
+ href = self.getAttribute('href').replace(/^#!\/NaCl\+/, '');
+
+ // chrome - runs fine with `_self`
+ // firefox - kills options page, use `_blank`
+ // compromise - use an iframe...
+ open(atob(href), 'native-frame');
+ };
+ });
+
+// CORS exception: SecurityError
+// MUST be { window }, never { top }
+let { hash } = window.location;
+
+if(hash.length > 1)
+ switch(hash = hash.replace(/^#!\//, '').toLowerCase()) {
+ /* #!/~COMMAND[:PARAMETER|PARAMETER...]
+ * #!/~save
+ * #!/~clear:all
+ */
+ case '~save':
+ setTimeout(async() => {
+ await saveOptions();
+
+ window.postMessage({ type: 'INITIALIZE' });
+ }, 1000);
+ break;
+
+ /* #!/SETTING[/SUB-SETTING]
+ * #!/radarr
+ * #!/advance-settings/api-keys
+ */
+ case '':
+ break;
+
+ default:
+ terminal.log(`Unknown event "${ hash }"`);
+ break;
+ };
+
+/* Functions that require some time */
+let Recall = {
+ '@auto': {}, // run at 100ms, and be recallable
+ '@0sec': {}, // run at 1ms
+ '@1sec': {}, // run at 1000ms
+};
+
+/* Counting sites that are in use */
+Recall['@auto'].CountEnabledSites = () =>
+ [...$('[counter-for="sites"i]', true)].map(e => {
+ let b_all = $('[id^="builtin_"]', true),
+ p_all = $('[id^="plugin_"]', true),
+ b_on = b_all.filter(e => e.checked).length,
+ p_on = p_all.filter(e => e.checked).length;
+
+ e.innerHTML = `${ (b_on + p_on) }/${ (b_all.length + p_all.length) }`
+
+ $('#all-builtin').checked = b_all.length == b_on;
+ $('#all-plugin').checked = p_all.length == p_on;
+ });
+
+/* Setting the DEV badge */
+Recall['@auto'].ToggleDeveloprBadge = (state = null) =>
+ [...$('[counter-for="devmode"i]', true)].map(e => {
+ if(state === null)
+ state = getOptionValues().DeveloperMode;
+
+ if(state)
+ return e.style.display = 'inline-block';
+ e.style.display = 'none';
+ });
+
+/* Set the version and color */
+Recall['@0sec'].SetVersionInfo = async() => {
+ let DM = getOptionValues().DeveloperMode;
+
+ function useVer(version) {
+ let remote = version.tag_name.slice(1, version.tag_name.length),
+ local = manifest.version,
+ verEl = $('#version'),
+ status;
+
+ switch(compareVer(remote, local)) {
+ case 0:
+ status = 'same';
+ verEl.setAttribute('title', `The installed version is the most recent. No update required`);
+ break;
+
+ case -1:
+ status = 'high';
+ verEl.setAttribute('title', `The installed version is ahead of GitHub. No update required`);
+ break;
+
+ case 1:
+ status = 'low';
+ verEl.href += (DM? '': '/latest');
+ verEl.setAttribute('title', `The installed version is behind GitHub. Update available`);
+ break;
+ }
+
+ verEl.innerHTML = `v${ manifest.version }`;
+ verEl.setAttribute('status', status);
+ }
+
+ if(DM)
+ await fetch('https://api.github.com/repos/SpaceK33z/web-to-plex/releases')
+ .then(response => response.json())
+ .then(versions => useVer(versions[0]));
+ else
+ await fetch('https://api.github.com/repos/SpaceK33z/web-to-plex/releases/latest')
+ .then(response => response.json())
+ .then(version => useVer(version));
+};
+
+for(let func in Recall) {
+ if(/^@/.test(func)) {
+ let f;
+
+ switch(func) {
+ case '@auto':
+ for(let fn in Recall[func]) {
+ f = Recall[func][fn];
+
+ Recall[fn] = f;
+ setTimeout(f, 100);
+ }
+ break;
+
+ case '@0sec':
+ for(let fn in Recall[func]) {
+ f = Recall[func][fn];
+
+ setTimeout(f, 1);
+ }
+ break;
+
+ case '@1sec':
+ for(let fn in Recall[func]) {
+ f = Recall[func][fn];
+
+ setTimeout(f, 1000);
+ }
+ break;
+ }
+ } else {
+ /* Do nothing... */
+ }
+}
+
+/* BWT Sorting Algorithm */
+function BWT(string = '') {
+ if(/^[\x32]*$/.test(string))
+ return '';
+
+ let _a = `\u0001${ string }`,
+ _b = `\u0001${ string }\u0001${ string }`,
+ p_ = [];
+
+ for(let i = 0; i < _a.length; i++)
+ p_.push(_b.slice(i, _a.length + i));
+
+ p_ = p_.sort();
+
+ return p_.map(P => P.slice(-1)[0]).join('');
+}
+
+/* BWT Desorting Algorithm */
+function iBWT(string = '') {
+ if(/^[\x32]*$/.test(string))
+ return '';
+
+ let a = string.split('');
+
+ let O = q => {
+ let x = 0;
+ for(let i = 0; i < a.length; i++)
+ if(a[i] < q)
+ x++;
+ return x;
+ };
+
+ let C = (n, q) => {
+ let x = 0;
+ for(let i = 0; i < n; i++)
+ if(a[i] === q)
+ x++;
+ return x;
+ };
+
+ let b = 0,
+ c = '',
+ d = a.length + 1;
+
+ while(a[b] !== '\u0001' && d--) {
+ c = a[b] + c;
+ b = O(a[b]) + C(b, a[b]);
+ }
+
+ return c;
+}
+
+/* LZW Compression Algorithm */
+function compress(string = '') {
+ let dictionary = {},
+ phrases = (string + ''),
+ phrase = phrases[0],
+ medium = [],
+ output = [],
+ index = 255,
+ character;
+
+ if(string.length < 1)
+ return;
+
+ let at = (w = phrase, d = dictionary) =>
+ (w.length > 1)?
+ d[`@${ w }`]:
+ w.charCodeAt(0);
+
+ for(let i = 1, l = phrases.length; i < l; i++)
+ if(dictionary[`@${ phrase }${ character = phrases[i] }`] !== undefined) {
+ phrase += character;
+ } else {
+ medium.push(at(phrase));
+ dictionary[`@${ phrase }${ character }`] = index++;
+ phrase = character;
+ }
+ medium.push(at(phrase));
+
+ for(let i = 0, l = medium.length; i < l; i++)
+ output.push(String.fromCharCode(medium[i]));
+
+ return output.join('');
+}
+
+/* LZW Decompression Algorithm */
+function decompress(string = '') {
+ let dictionary = {},
+ phrases = (string + ''),
+ character = phrases[0],
+ word = {
+ now: '',
+ last: character,
+ },
+ output = [character],
+ index = 255;
+
+ if(string.length < 1)
+ return;
+
+ for(let i = 1, l = phrases.length, code; i < l; i++) {
+ code = phrases.charCodeAt(i);
+
+ if(code < 255)
+ word.now = phrases[i];
+ else if((word.now = dictionary[`@${ code }`]) === undefined)
+ word.now = word.last + character;
+
+ output.push(word.now);
+ character = word.now[0];
+ dictionary[`@${ index++ }`] = word.last + character;
+ word.last = word.now;
+ }
+
+ return output.join('');
+}
+
+String.prototype.toCaps = String.prototype.toCaps || function toCaps(all) {
+ /** Titling Caplitalization
+ * Articles: a, an, & the
+ * Conjunctions: and, but, for, nor, or, so, & yet
+ * Prepositions: across, after, although, at, because, before, between, by, during, from, if, in, into, of, on, to, through, under, with, & without
+ */
+ let array = this.toLowerCase(),
+ titles = /(?!^|(?:an?|the)\s+)\b(a([st]|nd?|cross|fter|lthough)?|b(e(cause|fore|tween)?|ut|y)|during|from|in(to)?|[io][fn]|[fn]?or|the|[st]o|through|under|with(out)?|yet)(?!\s*$)\b/gi,
+ cap_exceptions = /([\|\"\(]\s*[a-z]|[\:\.\!\?]\s+[a-z]|(?:^\b|[^\'\-\+]\b)[^aeiouy\d\W]+\b)/gi, // Punctuation exceptions, e.g. "And not I"
+ all_exceptions = /\b((?:ww)?(?:m{1,4}(?:c?d(?:c{0,3}(?:x?l(?:x{0,3}(?:i?vi{0,3})?)?)?)?)?|c?d(?:c{0,3}(?:x?l(?:x{0,3}(?:i?vi{0,3})?)?)?)?|c{1,3}(?:x?l(?:x{0,3}(?:i?vi{0,3})?)?)?|x?l(?:x{0,3}(?:i?vi{0,3})?)?|x{1,3}(?:i?vi{0,3})?|i?vi{0,3}|i{1,3}))\b/gi, // Roman Numberals
+ cam_exceptions = /\b((?:mr?s|[sdjm]r|mx)|(?:adm|cm?dr?|chf|c[op][lmr]|cpt|gen|lt|mjr|sgt)|doc|hon|prof)(?:\.|\b)/gi, // Titles (Most Common?)
+ low_exceptions = /'([\w]+)/gi; // Apostrphe cases
+
+ array = array.split(/\s+/);
+
+ let index, length, string, word;
+ for(index = 0, length = array.length, string = [], word; index < length; index++) {
+ word = array[index];
+
+ if(word)
+ string.push( word[0].toUpperCase() + word.slice(1, word.length) );
+ }
+
+ string = string.join(' ');
+
+ if(!all)
+ string = string
+ .replace(titles, ($0, $1, $$, $_) => $1.toLowerCase())
+ .replace(all_exceptions, ($0, $1, $$, $_) => $1.toUpperCase())
+ .replace(cap_exceptions, ($0, $1, $$, $_) => $1.toUpperCase())
+ .replace(low_exceptions, ($0, $1, $$, $_) => $0.toLowerCase())
+ .replace(cam_exceptions, ($0, $1, $$, $_) => $1[0].toUpperCase() + $1.slice(1, $1.length).toLowerCase() + '.');
+
+ return string;
+};
+
+Object.filter = Object.filter || function filter(object, prejudice) {
+ if(!prejudice)
+ return object;
+
+ let results = {};
+
+ for(let key in object)
+ if(prejudice(key, object[key]))
+ results[key] = object[key];
+
+ return results;
+};
+
+function path(element) {
+ if(element.path)
+ return element.path;
+ else if(element.composedPath)
+ return element.composedPath();
+
+ let path = [];
+
+ while(element) {
+ path.push(element);
+
+ if(element.parentElement === undefined || element.parentElement === null) {
+ path.push(document, top);
+
+ return path;
+ }
+
+ element = element.parentElement;
+ }
+};
diff --git a/win/options/lodash.min.js b/win/options/lodash.min.js
new file mode 100644
index 0000000..a3c7dac
--- /dev/null
+++ b/win/options/lodash.min.js
@@ -0,0 +1,5 @@
+/* eslint-disable */
+// lodash 4.17.2
+(function(){function n(n,t){return n.set(t[0],t[1]),n}function t(n,t){return n.add(t),n}function r(n,t,r){switch(r.length){case 0:return n.call(t);case 1:return n.call(t,r[0]);case 2:return n.call(t,r[0],r[1]);case 3:return n.call(t,r[0],r[1],r[2])}return n.apply(t,r)}function e(n,t,r,e){for(var u=-1,i=null==n?0:n.length;++u-1}function c(n,t,r){for(var e=-1,u=null==n?0:n.length;++e-1;);return r}function C(n,t){for(var r=n.length;r--&&b(t,n[r],0)>-1;);return r}function U(n,t){for(var r=n.length,e=0;r--;)n[r]===t&&++e;return e}function B(n){return"\\"+Yr[n]}function T(n,t){return null==n?X:n[t]}function $(n){return Nr.test(n)}function D(n){return Pr.test(n)}function M(n){for(var t,r=[];!(t=n.next()).done;)r.push(t.value);return r}function F(n){var t=-1,r=Array(n.size);return n.forEach(function(n,e){r[++t]=[e,n]}),r}function N(n,t){return function(r){return n(t(r))}}function P(n,t){for(var r=-1,e=n.length,u=0,i=[];++r>>1,Tn=[["ary",wn],["bind",pn],["bindKey",vn],["curry",gn],["curryRight",yn],["flip",xn],["partial",dn],["partialRight",bn],["rearg",mn]],$n="[object Arguments]",Dn="[object Array]",Mn="[object AsyncFunction]",Fn="[object Boolean]",Nn="[object Date]",Pn="[object DOMException]",qn="[object Error]",Zn="[object Function]",Kn="[object GeneratorFunction]",Vn="[object Map]",Gn="[object Number]",Hn="[object Null]",Jn="[object Object]",Yn="[object Promise]",Qn="[object Proxy]",Xn="[object RegExp]",nt="[object Set]",tt="[object String]",rt="[object Symbol]",et="[object Undefined]",ut="[object WeakMap]",it="[object WeakSet]",ot="[object ArrayBuffer]",ft="[object DataView]",at="[object Float32Array]",ct="[object Float64Array]",lt="[object Int8Array]",st="[object Int16Array]",ht="[object Int32Array]",pt="[object Uint8Array]",vt="[object Uint8ClampedArray]",_t="[object Uint16Array]",gt="[object Uint32Array]",yt=/\b__p \+= '';/g,dt=/\b(__p \+=) '' \+/g,bt=/(__e\(.*?\)|\b__t\)) \+\n'';/g,wt=/&(?:amp|lt|gt|quot|#39);/g,mt=/[&<>"']/g,xt=RegExp(wt.source),jt=RegExp(mt.source),At=/<%-([\s\S]+?)%>/g,kt=/<%([\s\S]+?)%>/g,Ot=/<%=([\s\S]+?)%>/g,It=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,Rt=/^\w*$/,zt=/^\./,Et=/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g,St=/[\\^$.*+?()[\]{}|]/g,Wt=RegExp(St.source),Lt=/^\s+|\s+$/g,Ct=/^\s+/,Ut=/\s+$/,Bt=/\{(?:\n\/\* \[wrapped with .+\] \*\/)?\n?/,Tt=/\{\n\/\* \[wrapped with (.+)\] \*/,$t=/,? & /,Dt=/[^\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\x7f]+/g,Mt=/\\(\\)?/g,Ft=/\$\{([^\\}]*(?:\\.[^\\}]*)*)\}/g,Nt=/\w*$/,Pt=/^[-+]0x[0-9a-f]+$/i,qt=/^0b[01]+$/i,Zt=/^\[object .+?Constructor\]$/,Kt=/^0o[0-7]+$/i,Vt=/^(?:0|[1-9]\d*)$/,Gt=/[\xc0-\xd6\xd8-\xf6\xf8-\xff\u0100-\u017f]/g,Ht=/($^)/,Jt=/['\n\r\u2028\u2029\\]/g,Yt="\\ud800-\\udfff",Qt="\\u0300-\\u036f",Xt="\\ufe20-\\ufe2f",nr="\\u20d0-\\u20ff",tr=Qt+Xt+nr,rr="\\u2700-\\u27bf",er="a-z\\xdf-\\xf6\\xf8-\\xff",ur="\\xac\\xb1\\xd7\\xf7",ir="\\x00-\\x2f\\x3a-\\x40\\x5b-\\x60\\x7b-\\xbf",or="\\u2000-\\u206f",fr=" \\t\\x0b\\f\\xa0\\ufeff\\n\\r\\u2028\\u2029\\u1680\\u180e\\u2000\\u2001\\u2002\\u2003\\u2004\\u2005\\u2006\\u2007\\u2008\\u2009\\u200a\\u202f\\u205f\\u3000",ar="A-Z\\xc0-\\xd6\\xd8-\\xde",cr="\\ufe0e\\ufe0f",lr=ur+ir+or+fr,sr="['’]",hr="["+Yt+"]",pr="["+lr+"]",vr="["+tr+"]",_r="\\d+",gr="["+rr+"]",yr="["+er+"]",dr="[^"+Yt+lr+_r+rr+er+ar+"]",br="\\ud83c[\\udffb-\\udfff]",wr="(?:"+vr+"|"+br+")",mr="[^"+Yt+"]",xr="(?:\\ud83c[\\udde6-\\uddff]){2}",jr="[\\ud800-\\udbff][\\udc00-\\udfff]",Ar="["+ar+"]",kr="\\u200d",Or="(?:"+yr+"|"+dr+")",Ir="(?:"+Ar+"|"+dr+")",Rr="(?:"+sr+"(?:d|ll|m|re|s|t|ve))?",zr="(?:"+sr+"(?:D|LL|M|RE|S|T|VE))?",Er=wr+"?",Sr="["+cr+"]?",Wr="(?:"+kr+"(?:"+[mr,xr,jr].join("|")+")"+Sr+Er+")*",Lr="\\d*(?:(?:1st|2nd|3rd|(?![123])\\dth)\\b)",Cr="\\d*(?:(?:1ST|2ND|3RD|(?![123])\\dTH)\\b)",Ur=Sr+Er+Wr,Br="(?:"+[gr,xr,jr].join("|")+")"+Ur,Tr="(?:"+[mr+vr+"?",vr,xr,jr,hr].join("|")+")",$r=RegExp(sr,"g"),Dr=RegExp(vr,"g"),Mr=RegExp(br+"(?="+br+")|"+Tr+Ur,"g"),Fr=RegExp([Ar+"?"+yr+"+"+Rr+"(?="+[pr,Ar,"$"].join("|")+")",Ir+"+"+zr+"(?="+[pr,Ar+Or,"$"].join("|")+")",Ar+"?"+Or+"+"+Rr,Ar+"+"+zr,Cr,Lr,_r,Br].join("|"),"g"),Nr=RegExp("["+kr+Yt+tr+cr+"]"),Pr=/[a-z][A-Z]|[A-Z]{2,}[a-z]|[0-9][a-zA-Z]|[a-zA-Z][0-9]|[^a-zA-Z0-9 ]/,qr=["Array","Buffer","DataView","Date","Error","Float32Array","Float64Array","Function","Int8Array","Int16Array","Int32Array","Map","Math","Object","Promise","RegExp","Set","String","Symbol","TypeError","Uint8Array","Uint8ClampedArray","Uint16Array","Uint32Array","WeakMap","_","clearTimeout","isFinite","parseInt","setTimeout"],Zr=-1,Kr={};Kr[at]=Kr[ct]=Kr[lt]=Kr[st]=Kr[ht]=Kr[pt]=Kr[vt]=Kr[_t]=Kr[gt]=!0,Kr[$n]=Kr[Dn]=Kr[ot]=Kr[Fn]=Kr[ft]=Kr[Nn]=Kr[qn]=Kr[Zn]=Kr[Vn]=Kr[Gn]=Kr[Jn]=Kr[Xn]=Kr[nt]=Kr[tt]=Kr[ut]=!1;var Vr={};Vr[$n]=Vr[Dn]=Vr[ot]=Vr[ft]=Vr[Fn]=Vr[Nn]=Vr[at]=Vr[ct]=Vr[lt]=Vr[st]=Vr[ht]=Vr[Vn]=Vr[Gn]=Vr[Jn]=Vr[Xn]=Vr[nt]=Vr[tt]=Vr[rt]=Vr[pt]=Vr[vt]=Vr[_t]=Vr[gt]=!0,Vr[qn]=Vr[Zn]=Vr[ut]=!1;var Gr={"À":"A","Á":"A","Â":"A","Ã":"A","Ä":"A","Å":"A","à":"a","á":"a","â":"a","ã":"a","ä":"a","å":"a","Ç":"C","ç":"c","Ð":"D","ð":"d","È":"E","É":"E","Ê":"E","Ë":"E","è":"e","é":"e","ê":"e","ë":"e","Ì":"I","Í":"I","Î":"I","Ï":"I","ì":"i","í":"i","î":"i","ï":"i","Ñ":"N","ñ":"n","Ò":"O","Ó":"O","Ô":"O","Õ":"O","Ö":"O","Ø":"O","ò":"o","ó":"o","ô":"o","õ":"o","ö":"o","ø":"o","Ù":"U","Ú":"U","Û":"U","Ü":"U","ù":"u","ú":"u","û":"u","ü":"u","Ý":"Y","ý":"y","ÿ":"y","Æ":"Ae","æ":"ae","Þ":"Th","þ":"th","ß":"ss","Ā":"A","Ă":"A","Ą":"A","ā":"a","ă":"a","ą":"a","Ć":"C","Ĉ":"C","Ċ":"C","Č":"C","ć":"c","ĉ":"c","ċ":"c","č":"c","Ď":"D","Đ":"D","ď":"d","đ":"d","Ē":"E","Ĕ":"E","Ė":"E","Ę":"E","Ě":"E","ē":"e","ĕ":"e","ė":"e","ę":"e","ě":"e","Ĝ":"G","Ğ":"G","Ġ":"G","Ģ":"G","ĝ":"g","ğ":"g","ġ":"g","ģ":"g","Ĥ":"H","Ħ":"H","ĥ":"h","ħ":"h","Ĩ":"I","Ī":"I","Ĭ":"I","Į":"I","İ":"I","ĩ":"i","ī":"i","ĭ":"i","į":"i","ı":"i","Ĵ":"J","ĵ":"j","Ķ":"K","ķ":"k","ĸ":"k","Ĺ":"L","Ļ":"L","Ľ":"L","Ŀ":"L","Ł":"L","ĺ":"l","ļ":"l","ľ":"l","ŀ":"l","ł":"l","Ń":"N","Ņ":"N","Ň":"N","Ŋ":"N","ń":"n","ņ":"n","ň":"n","ŋ":"n","Ō":"O","Ŏ":"O","Ő":"O","ō":"o","ŏ":"o","ő":"o","Ŕ":"R","Ŗ":"R","Ř":"R","ŕ":"r","ŗ":"r","ř":"r","Ś":"S","Ŝ":"S","Ş":"S","Š":"S","ś":"s","ŝ":"s","ş":"s","š":"s","Ţ":"T","Ť":"T","Ŧ":"T","ţ":"t","ť":"t","ŧ":"t","Ũ":"U","Ū":"U","Ŭ":"U","Ů":"U","Ű":"U","Ų":"U","ũ":"u","ū":"u","ŭ":"u","ů":"u","ű":"u","ų":"u","Ŵ":"W","ŵ":"w","Ŷ":"Y","ŷ":"y","Ÿ":"Y","Ź":"Z","Ż":"Z","Ž":"Z","ź":"z","ż":"z","ž":"z","IJ":"IJ","ij":"ij","Œ":"Oe","œ":"oe","ʼn":"'n","ſ":"s"},Hr={"&":"&","<":"<",">":">",'"':""","'":"'"},Jr={"&":"&","<":"<",">":">",""":'"',"'":"'"},Yr={"\\":"\\","'":"'","\n":"n","\r":"r","\u2028":"u2028","\u2029":"u2029"},Qr=parseFloat,Xr=parseInt,ne="object"==typeof global&&global&&global.Object===Object&&global,te="object"==typeof self&&self&&self.Object===Object&&self,re=ne||te||Function("return this")(),ee="object"==typeof exports&&exports&&!exports.nodeType&&exports,ue=ee&&"object"==typeof module&&module&&!module.nodeType&&module,ie=ue&&ue.exports===ee,oe=ie&&ne.process,fe=function(){try{return oe&&oe.binding&&oe.binding("util")}catch(n){}}(),ae=fe&&fe.isArrayBuffer,ce=fe&&fe.isDate,le=fe&&fe.isMap,se=fe&&fe.isRegExp,he=fe&&fe.isSet,pe=fe&&fe.isTypedArray,ve=j("length"),_e=A(Gr),ge=A(Hr),ye=A(Jr),de=function _(A){function K(n){if(ca(n)&&!wh(n)&&!(n instanceof Dt)){if(n instanceof Y)return n;if(wl.call(n,"__wrapped__"))return uo(n)}return new Y(n)}function J(){}function Y(n,t){this.__wrapped__=n,this.__actions__=[],this.__chain__=!!t,this.__index__=0,this.__values__=X}function Dt(n){this.__wrapped__=n,this.__actions__=[],this.__dir__=1,this.__filtered__=!1,this.__iteratees__=[],this.__takeCount__=Cn,this.__views__=[]}function Yt(){var n=new Dt(this.__wrapped__);return n.__actions__=Fu(this.__actions__),n.__dir__=this.__dir__,n.__filtered__=this.__filtered__,n.__iteratees__=Fu(this.__iteratees__),n.__takeCount__=this.__takeCount__,n.__views__=Fu(this.__views__),n}function Qt(){if(this.__filtered__){var n=new Dt(this);n.__dir__=-1,n.__filtered__=!0}else n=this.clone(),n.__dir__*=-1;return n}function Xt(){var n=this.__wrapped__.value(),t=this.__dir__,r=wh(n),e=t<0,u=r?n.length:0,i=Ii(0,u,this.__views__),o=i.start,f=i.end,a=f-o,c=e?f:o-1,l=this.__iteratees__,s=l.length,h=0,p=Jl(a,this.__takeCount__);if(!r||u-1}function sr(n,t){var r=this.__data__,e=Cr(r,n);return e<0?(++this.size,r.push([n,t])):r[e][1]=t,this}function hr(n){var t=-1,r=null==n?0:n.length;for(this.clear();++t=t?n:t)),n}function Pr(n,t,r,e,i,o){var f,a=t&an,c=t&cn,l=t&ln;if(r&&(f=i?r(n,e,i,o):r(n)),f!==X)return f;if(!aa(n))return n;var s=wh(n);if(s){if(f=Ei(n),!a)return Fu(n,f)}else{var h=Es(n),p=h==Zn||h==Kn;if(xh(n))return zu(n,a);if(h==Jn||h==$n||p&&!i){if(f=c||p?{}:Si(n),!a)return c?qu(n,Tr(f,n)):Pu(n,Br(f,n))}else{if(!Vr[h])return i?n:{};f=Wi(n,h,Pr,a)}}o||(o=new mr);var v=o.get(n);if(v)return v;o.set(n,f);var _=l?c?bi:di:c?Za:qa,g=s?X:_(n);return u(g||n,function(e,u){g&&(u=e,e=n[u]),Lr(f,u,Pr(e,t,r,u,n,o))}),f}function Gr(n){var t=qa(n);return function(r){return Hr(r,n,t)}}function Hr(n,t,r){var e=r.length;if(null==n)return!e;for(n=sl(n);e--;){var u=r[e],i=t[u],o=n[u];if(o===X&&!(u in n)||!i(o))return!1}return!0}function Jr(n,t,r){if("function"!=typeof n)throw new vl(en);return Ls(function(){n.apply(X,r)},t)}function Yr(n,t,r,e){var u=-1,i=a,o=!0,f=n.length,s=[],h=t.length;if(!f)return s;r&&(t=l(t,E(r))),e?(i=c,o=!1):t.length>=tn&&(i=W,o=!1,t=new dr(t));n:for(;++uu?0:u+r),e=e===X||e>u?u:Oa(e),e<0&&(e+=u),e=r>e?0:Ia(e);r0&&r(f)?t>1?oe(f,t-1,r,e,u):s(u,f):e||(u[u.length]=f)}return u}function fe(n,t){return n&&ws(n,t,qa)}function ve(n,t){return n&&ms(n,t,qa)}function de(n,t){return f(t,function(t){return ia(n[t])})}function we(n,t){t=Iu(t,n);for(var r=0,e=t.length;null!=n&&rt}function Ae(n,t){return null!=n&&wl.call(n,t)}function ke(n,t){return null!=n&&t in sl(n)}function Oe(n,t,r){return n>=Jl(t,r)&&n=120&&p.length>=120)?new dr(o&&p):X}p=n[0];var v=-1,_=f[0];n:for(;++v-1;)f!==n&&Cl.call(f,a,1),Cl.call(n,a,1);return n}function ru(n,t){for(var r=n?t.length:0,e=r-1;r--;){var u=t[r];if(r==e||u!==i){var i=u;Ui(u)?Cl.call(n,u,1):bu(n,u)}}return n}function eu(n,t){return n+Pl(Xl()*(t-n+1))}function uu(n,t,r,e){for(var u=-1,i=Hl(Nl((t-n)/(r||1)),0),o=ol(i);i--;)o[e?i:++u]=n,n+=r;return o}function iu(n,t){var r="";if(!n||t<1||t>Sn)return r;do t%2&&(r+=n),t=Pl(t/2),t&&(n+=n);while(t);return r}function ou(n,t){return Cs(Hi(n,t,Cc),n+"")}function fu(n){return Rr(ec(n))}function au(n,t){var r=ec(n);return no(r,Nr(t,0,r.length))}function cu(n,t,r,e){if(!aa(n))return n;t=Iu(t,n);for(var u=-1,i=t.length,o=i-1,f=n;null!=f&&++uu?0:u+t),r=r>u?u:r,r<0&&(r+=u),u=t>r?0:r-t>>>0,t>>>=0;for(var i=ol(u);++e>>1,o=n[i];null!==o&&!wa(o)&&(r?o<=t:o=tn){var s=t?null:Os(n);if(s)return q(s);o=!1,u=W,l=new dr}else l=t?[]:f;n:for(;++e=e?n:su(n,t,r)}function zu(n,t){if(t)return n.slice();var r=n.length,e=El?El(r):new n.constructor(r);return n.copy(e),e}function Eu(n){var t=new n.constructor(n.byteLength);return new zl(t).set(new zl(n)),t}function Su(n,t){var r=t?Eu(n.buffer):n.buffer;return new n.constructor(r,n.byteOffset,n.byteLength)}function Wu(t,r,e){var u=r?e(F(t),an):F(t);return h(u,n,new t.constructor)}function Lu(n){var t=new n.constructor(n.source,Nt.exec(n));return t.lastIndex=n.lastIndex,t}function Cu(n,r,e){var u=r?e(q(n),an):q(n);return h(u,t,new n.constructor)}function Uu(n){return _s?sl(_s.call(n)):{}}function Bu(n,t){var r=t?Eu(n.buffer):n.buffer;return new n.constructor(r,n.byteOffset,n.length)}function Tu(n,t){if(n!==t){var r=n!==X,e=null===n,u=n===n,i=wa(n),o=t!==X,f=null===t,a=t===t,c=wa(t);if(!f&&!c&&!i&&n>t||i&&o&&a&&!f&&!c||e&&o&&a||!r&&a||!u)return 1;if(!e&&!i&&!c&&n=f)return a;var c=r[e];return a*("desc"==c?-1:1)}}return n.index-t.index}function Du(n,t,r,e){for(var u=-1,i=n.length,o=r.length,f=-1,a=t.length,c=Hl(i-o,0),l=ol(a+c),s=!e;++f1?r[u-1]:X,o=u>2?r[2]:X;for(i=n.length>3&&"function"==typeof i?(u--,i):X,o&&Bi(r[0],r[1],o)&&(i=u<3?X:i,u=1),t=sl(t);++e-1?u[i?t[o]:o]:X}}function ti(n){return yi(function(t){var r=t.length,e=r,u=Y.prototype.thru;for(n&&t.reverse();e--;){var i=t[e];if("function"!=typeof i)throw new vl(en);if(u&&!o&&"wrapper"==wi(i))var o=new Y([],!0)}for(e=o?e:r;++e=tn)return o.plant(e).value();for(var u=0,i=r?t[u].apply(this,n):e;++u1&&d.reverse(),s&&af))return!1;var c=i.get(n);if(c&&i.get(t))return c==t;var l=-1,s=!0,h=r&hn?new dr:X;for(i.set(n,t),i.set(t,n);++l1?"& ":"")+t[e],t=t.join(r>2?", ":" "),n.replace(Bt,"{\n/* [wrapped with "+t+"] */\n")}function Ci(n){return wh(n)||bh(n)||!!(Ul&&n&&n[Ul])}function Ui(n,t){return t=null==t?Sn:t,!!t&&("number"==typeof n||Vt.test(n))&&n>-1&&n%1==0&&n0){if(++t>=kn)return arguments[0]}else t=0;return n.apply(X,arguments)}}function no(n,t){var r=-1,e=n.length,u=e-1;for(t=t===X?e:t;++r=this.__values__.length,t=n?X:this.__values__[this.__index__++];return{done:n,value:t}}function of(){return this}function ff(n){for(var t,r=this;r instanceof J;){var e=uo(r);e.__index__=0,e.__values__=X,t?u.__wrapped__=e:t=e;var u=e;r=r.__wrapped__}return u.__wrapped__=n,t}function af(){var n=this.__wrapped__;if(n instanceof Dt){var t=n;return this.__actions__.length&&(t=new Dt(this)),t=t.reverse(),t.__actions__.push({func:tf,args:[So],thisArg:X}),new Y(t,this.__chain__)}return this.thru(So)}function cf(){return xu(this.__wrapped__,this.__actions__)}function lf(n,t,r){var e=wh(n)?o:ne;return r&&Bi(n,t,r)&&(t=X),e(n,xi(t,3))}function sf(n,t){var r=wh(n)?f:ue;return r(n,xi(t,3))}function hf(n,t){return oe(df(n,t),1)}function pf(n,t){return oe(df(n,t),En)}function vf(n,t,r){return r=r===X?1:Oa(r),oe(df(n,t),r)}function _f(n,t){var r=wh(n)?u:ds;return r(n,xi(t,3))}function gf(n,t){var r=wh(n)?i:bs;return r(n,xi(t,3))}function yf(n,t,r,e){n=Jf(n)?n:ec(n),r=r&&!e?Oa(r):0;var u=n.length;return r<0&&(r=Hl(u+r,0)),ba(n)?r<=u&&n.indexOf(t,r)>-1:!!u&&b(n,t,r)>-1}function df(n,t){var r=wh(n)?l:Ze;return r(n,xi(t,3))}function bf(n,t,r,e){return null==n?[]:(wh(t)||(t=null==t?[]:[t]),r=e?X:r,wh(r)||(r=null==r?[]:[r]),Ye(n,t,r))}function wf(n,t,r){var e=wh(n)?h:k,u=arguments.length<3;return e(n,xi(t,4),r,u,ds)}function mf(n,t,r){var e=wh(n)?p:k,u=arguments.length<3;return e(n,xi(t,4),r,u,bs)}function xf(n,t){var r=wh(n)?f:ue;return r(n,Bf(xi(t,3)))}function jf(n){var t=wh(n)?Rr:fu;return t(n)}function Af(n,t,r){t=(r?Bi(n,t,r):t===X)?1:Oa(t);var e=wh(n)?zr:au;return e(n,t)}function kf(n){var t=wh(n)?Er:lu;return t(n)}function Of(n){if(null==n)return 0;if(Jf(n))return ba(n)?G(n):n.length;var t=Es(n);return t==Vn||t==nt?n.size:Ne(n).length}function If(n,t,r){var e=wh(n)?v:hu;return r&&Bi(n,t,r)&&(t=X),e(n,xi(t,3))}function Rf(n,t){if("function"!=typeof t)throw new vl(en);return n=Oa(n),function(){if(--n<1)return t.apply(this,arguments)}}function zf(n,t,r){return t=r?X:t,t=n&&null==t?n.length:t,pi(n,wn,X,X,X,X,t)}function Ef(n,t){var r;if("function"!=typeof t)throw new vl(en);return n=Oa(n),function(){return--n>0&&(r=t.apply(this,arguments)),n<=1&&(t=X),r}}function Sf(n,t,r){t=r?X:t;var e=pi(n,gn,X,X,X,X,X,t);return e.placeholder=Sf.placeholder,e}function Wf(n,t,r){t=r?X:t;var e=pi(n,yn,X,X,X,X,X,t);return e.placeholder=Wf.placeholder,e}function Lf(n,t,r){function e(t){var r=h,e=p;return h=p=X,d=t,_=n.apply(e,r)}function u(n){return d=n,g=Ls(f,t),b?e(n):_}function i(n){var r=n-y,e=n-d,u=t-r;return w?Jl(u,v-e):u}function o(n){var r=n-y,e=n-d;return y===X||r>=t||r<0||w&&e>=v}function f(){var n=ah();return o(n)?a(n):void(g=Ls(f,i(n)))}function a(n){return g=X,m&&h?e(n):(h=p=X,_)}function c(){g!==X&&ks(g),d=0,h=y=p=g=X}function l(){return g===X?_:a(ah())}function s(){var n=ah(),r=o(n);if(h=arguments,p=this,y=n,r){if(g===X)return u(y);if(w)return g=Ls(f,t),e(y)}return g===X&&(g=Ls(f,t)),_}var h,p,v,_,g,y,d=0,b=!1,w=!1,m=!0;if("function"!=typeof n)throw new vl(en);return t=Ra(t)||0,aa(r)&&(b=!!r.leading,w="maxWait"in r,v=w?Hl(Ra(r.maxWait)||0,t):v,m="trailing"in r?!!r.trailing:m),s.cancel=c,s.flush=l,s}function Cf(n){return pi(n,xn)}function Uf(n,t){if("function"!=typeof n||null!=t&&"function"!=typeof t)throw new vl(en);var r=function(){var e=arguments,u=t?t.apply(this,e):e[0],i=r.cache;if(i.has(u))return i.get(u);var o=n.apply(this,e);return r.cache=i.set(u,o)||i,o};return r.cache=new(Uf.Cache||hr),r}function Bf(n){if("function"!=typeof n)throw new vl(en);return function(){var t=arguments;switch(t.length){case 0:return!n.call(this);case 1:return!n.call(this,t[0]);case 2:return!n.call(this,t[0],t[1]);case 3:return!n.call(this,t[0],t[1],t[2])}return!n.apply(this,t)}}function Tf(n){return Ef(2,n)}function $f(n,t){if("function"!=typeof n)throw new vl(en);return t=t===X?t:Oa(t),ou(n,t)}function Df(n,t){if("function"!=typeof n)throw new vl(en);return t=t===X?0:Hl(Oa(t),0),ou(function(e){var u=e[t],i=Ru(e,0,t);return u&&s(i,u),r(n,this,i)})}function Mf(n,t,r){var e=!0,u=!0;if("function"!=typeof n)throw new vl(en);return aa(r)&&(e="leading"in r?!!r.leading:e,u="trailing"in r?!!r.trailing:u),Lf(n,t,{leading:e,maxWait:t,trailing:u})}function Ff(n){return zf(n,1)}function Nf(n,t){return vh(Ou(t),n)}function Pf(){if(!arguments.length)return[];var n=arguments[0];return wh(n)?n:[n]}function qf(n){return Pr(n,ln)}function Zf(n,t){return t="function"==typeof t?t:X,Pr(n,ln,t)}function Kf(n){return Pr(n,an|ln)}function Vf(n,t){return t="function"==typeof t?t:X,Pr(n,an|ln,t)}function Gf(n,t){return null==t||Hr(n,t,qa(t))}function Hf(n,t){return n===t||n!==n&&t!==t}function Jf(n){return null!=n&&fa(n.length)&&!ia(n)}function Yf(n){return ca(n)&&Jf(n)}function Qf(n){return n===!0||n===!1||ca(n)&&xe(n)==Fn}function Xf(n){return ca(n)&&1===n.nodeType&&!ya(n)}function na(n){if(null==n)return!0;if(Jf(n)&&(wh(n)||"string"==typeof n||"function"==typeof n.splice||xh(n)||Ih(n)||bh(n)))return!n.length;var t=Es(n);if(t==Vn||t==nt)return!n.size;if(Fi(n))return!Ne(n).length;for(var r in n)if(wl.call(n,r))return!1;return!0}function ta(n,t){return Le(n,t)}function ra(n,t,r){r="function"==typeof r?r:X;var e=r?r(n,t):X;return e===X?Le(n,t,X,r):!!e}function ea(n){if(!ca(n))return!1;var t=xe(n);return t==qn||t==Pn||"string"==typeof n.message&&"string"==typeof n.name&&!ya(n)}function ua(n){return"number"==typeof n&&Kl(n)}function ia(n){if(!aa(n))return!1;var t=xe(n);return t==Zn||t==Kn||t==Mn||t==Qn}function oa(n){return"number"==typeof n&&n==Oa(n)}function fa(n){return"number"==typeof n&&n>-1&&n%1==0&&n<=Sn}function aa(n){var t=typeof n;return null!=n&&("object"==t||"function"==t)}function ca(n){return null!=n&&"object"==typeof n}function la(n,t){return n===t||Be(n,t,Ai(t))}function sa(n,t,r){return r="function"==typeof r?r:X,Be(n,t,Ai(t),r)}function ha(n){return ga(n)&&n!=+n}function pa(n){if(Ss(n))throw new al(rn);return Te(n)}function va(n){return null===n}function _a(n){return null==n}function ga(n){return"number"==typeof n||ca(n)&&xe(n)==Gn}function ya(n){if(!ca(n)||xe(n)!=Jn)return!1;var t=Sl(n);if(null===t)return!0;var r=wl.call(t,"constructor")&&t.constructor;return"function"==typeof r&&r instanceof r&&bl.call(r)==Al}function da(n){return oa(n)&&n>=-Sn&&n<=Sn}function ba(n){return"string"==typeof n||!wh(n)&&ca(n)&&xe(n)==tt}function wa(n){return"symbol"==typeof n||ca(n)&&xe(n)==rt}function ma(n){return n===X}function xa(n){return ca(n)&&Es(n)==ut}function ja(n){return ca(n)&&xe(n)==it}function Aa(n){if(!n)return[];if(Jf(n))return ba(n)?H(n):Fu(n);if(Bl&&n[Bl])return M(n[Bl]());var t=Es(n),r=t==Vn?F:t==nt?q:ec;return r(n)}function ka(n){if(!n)return 0===n?n:0;if(n=Ra(n),n===En||n===-En){var t=n<0?-1:1;return t*Wn}return n===n?n:0}function Oa(n){var t=ka(n),r=t%1;return t===t?r?t-r:t:0}function Ia(n){return n?Nr(Oa(n),0,Cn):0}function Ra(n){if("number"==typeof n)return n;if(wa(n))return Ln;if(aa(n)){var t="function"==typeof n.valueOf?n.valueOf():n;n=aa(t)?t+"":t}if("string"!=typeof n)return 0===n?n:+n;n=n.replace(Lt,"");var r=qt.test(n);return r||Kt.test(n)?Xr(n.slice(2),r?2:8):Pt.test(n)?Ln:+n}function za(n){return Nu(n,Za(n))}function Ea(n){return Nr(Oa(n),-Sn,Sn)}function Sa(n){return null==n?"":yu(n)}function Wa(n,t){var r=ys(n);return null==t?r:Br(r,t)}function La(n,t){return y(n,xi(t,3),fe)}function Ca(n,t){return y(n,xi(t,3),ve)}function Ua(n,t){return null==n?n:ws(n,xi(t,3),Za)}function Ba(n,t){return null==n?n:ms(n,xi(t,3),Za)}function Ta(n,t){return n&&fe(n,xi(t,3))}function $a(n,t){return n&&ve(n,xi(t,3))}function Da(n){return null==n?[]:de(n,qa(n))}function Ma(n){return null==n?[]:de(n,Za(n))}function Fa(n,t,r){var e=null==n?X:we(n,t);return e===X?r:e}function Na(n,t){return null!=n&&zi(n,t,Ae)}function Pa(n,t){return null!=n&&zi(n,t,ke)}function qa(n){return Jf(n)?Ir(n):Ne(n)}function Za(n){return Jf(n)?Ir(n,!0):Pe(n)}function Ka(n,t){var r={};return t=xi(t,3),fe(n,function(n,e,u){Mr(r,t(n,e,u),n)}),r}function Va(n,t){var r={};return t=xi(t,3),fe(n,function(n,e,u){Mr(r,e,t(n,e,u))}),r}function Ga(n,t){return Ha(n,Bf(xi(t)))}function Ha(n,t){if(null==n)return{};var r=l(bi(n),function(n){return[n]});return t=xi(t),Xe(n,r,function(n,r){return t(n,r[0])})}function Ja(n,t,r){t=Iu(t,n);var e=-1,u=t.length;for(u||(u=1,n=X);++et){var e=n;n=t,t=e}if(r||n%1||t%1){var u=Xl();return Jl(n+u*(t-n+Qr("1e-"+((u+"").length-1))),t)}return eu(n,t)}function ac(n){return Xh(Sa(n).toLowerCase())}function cc(n){return n=Sa(n),n&&n.replace(Gt,_e).replace(Dr,"")}function lc(n,t,r){n=Sa(n),t=yu(t);var e=n.length;r=r===X?e:Nr(Oa(r),0,e);var u=r;return r-=t.length,r>=0&&n.slice(r,u)==t}function sc(n){return n=Sa(n),n&&jt.test(n)?n.replace(mt,ge):n}function hc(n){return n=Sa(n),n&&Wt.test(n)?n.replace(St,"\\$&"):n}function pc(n,t,r){n=Sa(n),t=Oa(t);var e=t?G(n):0;if(!t||e>=t)return n;var u=(t-e)/2;return oi(Pl(u),r)+n+oi(Nl(u),r)}function vc(n,t,r){n=Sa(n),t=Oa(t);var e=t?G(n):0;return t&&e>>0)?(n=Sa(n),n&&("string"==typeof t||null!=t&&!kh(t))&&(t=yu(t),!t&&$(n))?Ru(H(n),0,r):n.split(t,r)):[]}function wc(n,t,r){return n=Sa(n),r=Nr(Oa(r),0,n.length),t=yu(t),n.slice(r,r+t.length)==t}function mc(n,t,r){var e=K.templateSettings;r&&Bi(n,t,r)&&(t=X),n=Sa(n),t=Wh({},t,e,Sr);var u,i,o=Wh({},t.imports,e.imports,Sr),f=qa(o),a=S(o,f),c=0,l=t.interpolate||Ht,s="__p += '",h=hl((t.escape||Ht).source+"|"+l.source+"|"+(l===Ot?Ft:Ht).source+"|"+(t.evaluate||Ht).source+"|$","g"),p="//# sourceURL="+("sourceURL"in t?t.sourceURL:"lodash.templateSources["+ ++Zr+"]")+"\n";n.replace(h,function(t,r,e,o,f,a){return e||(e=o),s+=n.slice(c,a).replace(Jt,B),r&&(u=!0,s+="' +\n__e("+r+") +\n'"),f&&(i=!0,s+="';\n"+f+";\n__p += '"),e&&(s+="' +\n((__t = ("+e+")) == null ? '' : __t) +\n'"),c=a+t.length,t}),s+="';\n";var v=t.variable;v||(s="with (obj) {\n"+s+"\n}\n"),s=(i?s.replace(yt,""):s).replace(dt,"$1").replace(bt,"$1;"),s="function("+(v||"obj")+") {\n"+(v?"":"obj || (obj = {});\n")+"var __t, __p = ''"+(u?", __e = _.escape":"")+(i?", __j = Array.prototype.join;\nfunction print() { __p += __j.call(arguments, '') }\n":";\n")+s+"return __p\n}";var _=np(function(){return cl(f,p+"return "+s).apply(X,a)});if(_.source=s,ea(_))throw _;return _}function xc(n){return Sa(n).toLowerCase()}function jc(n){return Sa(n).toUpperCase()}function Ac(n,t,r){if(n=Sa(n),n&&(r||t===X))return n.replace(Lt,"");if(!n||!(t=yu(t)))return n;var e=H(n),u=H(t),i=L(e,u),o=C(e,u)+1;return Ru(e,i,o).join("")}function kc(n,t,r){if(n=Sa(n),n&&(r||t===X))return n.replace(Ut,"");if(!n||!(t=yu(t)))return n;var e=H(n),u=C(e,H(t))+1;return Ru(e,0,u).join("")}function Oc(n,t,r){if(n=Sa(n),n&&(r||t===X))return n.replace(Ct,"");if(!n||!(t=yu(t)))return n;var e=H(n),u=L(e,H(t));return Ru(e,u).join("")}function Ic(n,t){var r=jn,e=An;if(aa(t)){var u="separator"in t?t.separator:u;r="length"in t?Oa(t.length):r,e="omission"in t?yu(t.omission):e}n=Sa(n);var i=n.length;if($(n)){var o=H(n);i=o.length}if(r>=i)return n;var f=r-G(e);if(f<1)return e;var a=o?Ru(o,0,f).join(""):n.slice(0,f);if(u===X)return a+e;if(o&&(f+=a.length-f),kh(u)){if(n.slice(f).search(u)){var c,l=a;for(u.global||(u=hl(u.source,Sa(Nt.exec(u))+"g")),u.lastIndex=0;c=u.exec(l);)var s=c.index;a=a.slice(0,s===X?f:s)}}else if(n.indexOf(yu(u),f)!=f){var h=a.lastIndexOf(u);h>-1&&(a=a.slice(0,h))}return a+e}function Rc(n){return n=Sa(n),n&&xt.test(n)?n.replace(wt,ye):n}function zc(n,t,r){return n=Sa(n),t=r?X:t,t===X?D(n)?Q(n):g(n):n.match(t)||[]}function Ec(n){var t=null==n?0:n.length,e=xi();return n=t?l(n,function(n){if("function"!=typeof n[1])throw new vl(en);return[e(n[0]),n[1]]}):[],ou(function(e){for(var u=-1;++uSn)return[];var r=Cn,e=Jl(n,Cn);t=xi(t),n-=Cn;for(var u=R(e,t);++r1?n[t-1]:X;return r="function"==typeof r?(n.pop(),r):X,Jo(n,r)}),Xs=yi(function(n){var t=n.length,r=t?n[0]:0,e=this.__wrapped__,u=function(t){return Fr(t,n)};return!(t>1||this.__actions__.length)&&e instanceof Dt&&Ui(r)?(e=e.slice(r,+r+(t?1:0)),e.__actions__.push({func:tf,args:[u],thisArg:X}),new Y(e,this.__chain__).thru(function(n){return t&&!n.length&&n.push(X),n})):this.thru(u)}),nh=Zu(function(n,t,r){wl.call(n,r)?++n[r]:Mr(n,r,1)}),th=ni(po),rh=ni(vo),eh=Zu(function(n,t,r){wl.call(n,r)?n[r].push(t):Mr(n,r,[t])}),uh=ou(function(n,t,e){var u=-1,i="function"==typeof t,o=Jf(n)?ol(n.length):[];return ds(n,function(n){o[++u]=i?r(t,n,e):ze(n,t,e)}),o}),ih=Zu(function(n,t,r){Mr(n,r,t)}),oh=Zu(function(n,t,r){n[r?0:1].push(t)},function(){return[[],[]]}),fh=ou(function(n,t){if(null==n)return[];var r=t.length;return r>1&&Bi(n,t[0],t[1])?t=[]:r>2&&Bi(t[0],t[1],t[2])&&(t=[t[0]]),Ye(n,oe(t,1),[])}),ah=Ml||function(){return re.Date.now()},ch=ou(function(n,t,r){var e=pn;if(r.length){var u=P(r,mi(ch));e|=dn}return pi(n,e,t,r,u)}),lh=ou(function(n,t,r){var e=pn|vn;if(r.length){var u=P(r,mi(lh));e|=dn}return pi(t,e,n,r,u)}),sh=ou(function(n,t){return Jr(n,1,t)}),hh=ou(function(n,t,r){return Jr(n,Ra(t)||0,r)});Uf.Cache=hr;var ph=As(function(n,t){t=1==t.length&&wh(t[0])?l(t[0],E(xi())):l(oe(t,1),E(xi()));var e=t.length;return ou(function(u){for(var i=-1,o=Jl(u.length,e);++i=t}),bh=Ee(function(){return arguments}())?Ee:function(n){return ca(n)&&wl.call(n,"callee")&&!Ll.call(n,"callee")},wh=ol.isArray,mh=ae?E(ae):Se,xh=Zl||Zc,jh=ce?E(ce):We,Ah=le?E(le):Ue,kh=se?E(se):$e,Oh=he?E(he):De,Ih=pe?E(pe):Me,Rh=ci(qe),zh=ci(function(n,t){return n<=t}),Eh=Ku(function(n,t){if(Fi(t)||Jf(t))return void Nu(t,qa(t),n);for(var r in t)wl.call(t,r)&&Lr(n,r,t[r])}),Sh=Ku(function(n,t){Nu(t,Za(t),n)}),Wh=Ku(function(n,t,r,e){Nu(t,Za(t),n,e)}),Lh=Ku(function(n,t,r,e){Nu(t,qa(t),n,e)}),Ch=yi(Fr),Uh=ou(function(n){return n.push(X,Sr),r(Wh,X,n)}),Bh=ou(function(n){return n.push(X,Ki),r(Fh,X,n)}),Th=ei(function(n,t,r){n[t]=r},Wc(Cc)),$h=ei(function(n,t,r){wl.call(n,t)?n[t].push(r):n[t]=[r]},xi),Dh=ou(ze),Mh=Ku(function(n,t,r){Ge(n,t,r)}),Fh=Ku(function(n,t,r,e){Ge(n,t,r,e)}),Nh=yi(function(n,t){var r={};if(null==n)return r;var e=!1;t=l(t,function(t){return t=Iu(t,n),e||(e=t.length>1),t}),Nu(n,bi(n),r),e&&(r=Pr(r,an|cn|ln));for(var u=t.length;u--;)bu(r,t[u]);return r}),Ph=yi(function(n,t){return null==n?{}:Qe(n,t)}),qh=hi(qa),Zh=hi(Za),Kh=Yu(function(n,t,r){return t=t.toLowerCase(),n+(r?ac(t):t)}),Vh=Yu(function(n,t,r){return n+(r?"-":"")+t.toLowerCase()}),Gh=Yu(function(n,t,r){return n+(r?" ":"")+t.toLowerCase()}),Hh=Ju("toLowerCase"),Jh=Yu(function(n,t,r){return n+(r?"_":"")+t.toLowerCase()}),Yh=Yu(function(n,t,r){return n+(r?" ":"")+Xh(t)}),Qh=Yu(function(n,t,r){return n+(r?" ":"")+t.toUpperCase()}),Xh=Ju("toUpperCase"),np=ou(function(n,t){try{return r(n,X,t)}catch(n){return ea(n)?n:new al(n)}}),tp=yi(function(n,t){return u(t,function(t){t=to(t),Mr(n,t,ch(n[t],n))}),n}),rp=ti(),ep=ti(!0),up=ou(function(n,t){return function(r){return ze(r,n,t)}}),ip=ou(function(n,t){return function(r){return ze(n,r,t)}}),op=ii(l),fp=ii(o),ap=ii(v),cp=ai(),lp=ai(!0),sp=ui(function(n,t){return n+t},0),hp=si("ceil"),pp=ui(function(n,t){return n/t},1),vp=si("floor"),_p=ui(function(n,t){return n*t},1),gp=si("round"),yp=ui(function(n,t){return n-t},0);return K.after=Rf,K.ary=zf,K.assign=Eh,K.assignIn=Sh,K.assignInWith=Wh,K.assignWith=Lh,K.at=Ch,K.before=Ef,K.bind=ch,K.bindAll=tp,K.bindKey=lh,K.castArray=Pf,K.chain=Xo,K.chunk=io,K.compact=oo,K.concat=fo,K.cond=Ec,K.conforms=Sc,K.constant=Wc,K.countBy=nh,K.create=Wa,K.curry=Sf,K.curryRight=Wf,K.debounce=Lf,K.defaults=Uh,K.defaultsDeep=Bh,K.defer=sh,K.delay=hh,K.difference=Bs,K.differenceBy=Ts,K.differenceWith=$s,K.drop=ao,K.dropRight=co,
+K.dropRightWhile=lo,K.dropWhile=so,K.fill=ho,K.filter=sf,K.flatMap=hf,K.flatMapDeep=pf,K.flatMapDepth=vf,K.flatten=_o,K.flattenDeep=go,K.flattenDepth=yo,K.flip=Cf,K.flow=rp,K.flowRight=ep,K.fromPairs=bo,K.functions=Da,K.functionsIn=Ma,K.groupBy=eh,K.initial=xo,K.intersection=Ds,K.intersectionBy=Ms,K.intersectionWith=Fs,K.invert=Th,K.invertBy=$h,K.invokeMap=uh,K.iteratee=Uc,K.keyBy=ih,K.keys=qa,K.keysIn=Za,K.map=df,K.mapKeys=Ka,K.mapValues=Va,K.matches=Bc,K.matchesProperty=Tc,K.memoize=Uf,K.merge=Mh,K.mergeWith=Fh,K.method=up,K.methodOf=ip,K.mixin=$c,K.negate=Bf,K.nthArg=Fc,K.omit=Nh,K.omitBy=Ga,K.once=Tf,K.orderBy=bf,K.over=op,K.overArgs=ph,K.overEvery=fp,K.overSome=ap,K.partial=vh,K.partialRight=_h,K.partition=oh,K.pick=Ph,K.pickBy=Ha,K.property=Nc,K.propertyOf=Pc,K.pull=Ns,K.pullAll=Io,K.pullAllBy=Ro,K.pullAllWith=zo,K.pullAt=Ps,K.range=cp,K.rangeRight=lp,K.rearg=gh,K.reject=xf,K.remove=Eo,K.rest=$f,K.reverse=So,K.sampleSize=Af,K.set=Ya,K.setWith=Qa,K.shuffle=kf,K.slice=Wo,K.sortBy=fh,K.sortedUniq=Do,K.sortedUniqBy=Mo,K.split=bc,K.spread=Df,K.tail=Fo,K.take=No,K.takeRight=Po,K.takeRightWhile=qo,K.takeWhile=Zo,K.tap=nf,K.throttle=Mf,K.thru=tf,K.toArray=Aa,K.toPairs=qh,K.toPairsIn=Zh,K.toPath=Jc,K.toPlainObject=za,K.transform=Xa,K.unary=Ff,K.union=qs,K.unionBy=Zs,K.unionWith=Ks,K.uniq=Ko,K.uniqBy=Vo,K.uniqWith=Go,K.unset=nc,K.unzip=Ho,K.unzipWith=Jo,K.update=tc,K.updateWith=rc,K.values=ec,K.valuesIn=uc,K.without=Vs,K.words=zc,K.wrap=Nf,K.xor=Gs,K.xorBy=Hs,K.xorWith=Js,K.zip=Ys,K.zipObject=Yo,K.zipObjectDeep=Qo,K.zipWith=Qs,K.entries=qh,K.entriesIn=Zh,K.extend=Sh,K.extendWith=Wh,$c(K,K),K.add=sp,K.attempt=np,K.camelCase=Kh,K.capitalize=ac,K.ceil=hp,K.clamp=ic,K.clone=qf,K.cloneDeep=Kf,K.cloneDeepWith=Vf,K.cloneWith=Zf,K.conformsTo=Gf,K.deburr=cc,K.defaultTo=Lc,K.divide=pp,K.endsWith=lc,K.eq=Hf,K.escape=sc,K.escapeRegExp=hc,K.every=lf,K.find=th,K.findIndex=po,K.findKey=La,K.findLast=rh,K.findLastIndex=vo,K.findLastKey=Ca,K.floor=vp,K.forEach=_f,K.forEachRight=gf,K.forIn=Ua,K.forInRight=Ba,K.forOwn=Ta,K.forOwnRight=$a,K.get=Fa,K.gt=yh,K.gte=dh,K.has=Na,K.hasIn=Pa,K.head=wo,K.identity=Cc,K.includes=yf,K.indexOf=mo,K.inRange=oc,K.invoke=Dh,K.isArguments=bh,K.isArray=wh,K.isArrayBuffer=mh,K.isArrayLike=Jf,K.isArrayLikeObject=Yf,K.isBoolean=Qf,K.isBuffer=xh,K.isDate=jh,K.isElement=Xf,K.isEmpty=na,K.isEqual=ta,K.isEqualWith=ra,K.isError=ea,K.isFinite=ua,K.isFunction=ia,K.isInteger=oa,K.isLength=fa,K.isMap=Ah,K.isMatch=la,K.isMatchWith=sa,K.isNaN=ha,K.isNative=pa,K.isNil=_a,K.isNull=va,K.isNumber=ga,K.isObject=aa,K.isObjectLike=ca,K.isPlainObject=ya,K.isRegExp=kh,K.isSafeInteger=da,K.isSet=Oh,K.isString=ba,K.isSymbol=wa,K.isTypedArray=Ih,K.isUndefined=ma,K.isWeakMap=xa,K.isWeakSet=ja,K.join=jo,K.kebabCase=Vh,K.last=Ao,K.lastIndexOf=ko,K.lowerCase=Gh,K.lowerFirst=Hh,K.lt=Rh,K.lte=zh,K.max=Qc,K.maxBy=Xc,K.mean=nl,K.meanBy=tl,K.min=rl,K.minBy=el,K.stubArray=qc,K.stubFalse=Zc,K.stubObject=Kc,K.stubString=Vc,K.stubTrue=Gc,K.multiply=_p,K.nth=Oo,K.noConflict=Dc,K.noop=Mc,K.now=ah,K.pad=pc,K.padEnd=vc,K.padStart=_c,K.parseInt=gc,K.random=fc,K.reduce=wf,K.reduceRight=mf,K.repeat=yc,K.replace=dc,K.result=Ja,K.round=gp,K.runInContext=_,K.sample=jf,K.size=Of,K.snakeCase=Jh,K.some=If,K.sortedIndex=Lo,K.sortedIndexBy=Co,K.sortedIndexOf=Uo,K.sortedLastIndex=Bo,K.sortedLastIndexBy=To,K.sortedLastIndexOf=$o,K.startCase=Yh,K.startsWith=wc,K.subtract=yp,K.sum=ul,K.sumBy=il,K.template=mc,K.times=Hc,K.toFinite=ka,K.toInteger=Oa,K.toLength=Ia,K.toLower=xc,K.toNumber=Ra,K.toSafeInteger=Ea,K.toString=Sa,K.toUpper=jc,K.trim=Ac,K.trimEnd=kc,K.trimStart=Oc,K.truncate=Ic,K.unescape=Rc,K.uniqueId=Yc,K.upperCase=Qh,K.upperFirst=Xh,K.each=_f,K.eachRight=gf,K.first=wo,$c(K,function(){var n={};return fe(K,function(t,r){wl.call(K.prototype,r)||(n[r]=t)}),n}(),{chain:!1}),K.VERSION=nn,u(["bind","bindKey","curry","curryRight","partial","partialRight"],function(n){K[n].placeholder=K}),u(["drop","take"],function(n,t){Dt.prototype[n]=function(r){var e=this.__filtered__;if(e&&!t)return new Dt(this);r=r===X?1:Hl(Oa(r),0);var u=this.clone();return e?u.__takeCount__=Jl(r,u.__takeCount__):u.__views__.push({size:Jl(r,Cn),type:n+(u.__dir__<0?"Right":"")}),u},Dt.prototype[n+"Right"]=function(t){return this.reverse()[n](t).reverse()}}),u(["filter","map","takeWhile"],function(n,t){var r=t+1,e=r==In||r==zn;Dt.prototype[n]=function(n){var t=this.clone();return t.__iteratees__.push({iteratee:xi(n,3),type:r}),t.__filtered__=t.__filtered__||e,t}}),u(["head","last"],function(n,t){var r="take"+(t?"Right":"");Dt.prototype[n]=function(){return this[r](1).value()[0]}}),u(["initial","tail"],function(n,t){var r="drop"+(t?"":"Right");Dt.prototype[n]=function(){return this.__filtered__?new Dt(this):this[r](1)}}),Dt.prototype.compact=function(){return this.filter(Cc)},Dt.prototype.find=function(n){return this.filter(n).head()},Dt.prototype.findLast=function(n){return this.reverse().find(n)},Dt.prototype.invokeMap=ou(function(n,t){return"function"==typeof n?new Dt(this):this.map(function(r){return ze(r,n,t)})}),Dt.prototype.reject=function(n){return this.filter(Bf(xi(n)))},Dt.prototype.slice=function(n,t){n=Oa(n);var r=this;return r.__filtered__&&(n>0||t<0)?new Dt(r):(n<0?r=r.takeRight(-n):n&&(r=r.drop(n)),t!==X&&(t=Oa(t),r=t<0?r.dropRight(-t):r.take(t-n)),r)},Dt.prototype.takeRightWhile=function(n){return this.reverse().takeWhile(n).reverse()},Dt.prototype.toArray=function(){return this.take(Cn)},fe(Dt.prototype,function(n,t){var r=/^(?:filter|find|map|reject)|While$/.test(t),e=/^(?:head|last)$/.test(t),u=K[e?"take"+("last"==t?"Right":""):t],i=e||/^find/.test(t);u&&(K.prototype[t]=function(){var t=this.__wrapped__,o=e?[1]:arguments,f=t instanceof Dt,a=o[0],c=f||wh(t),l=function(n){var t=u.apply(K,s([n],o));return e&&h?t[0]:t};c&&r&&"function"==typeof a&&1!=a.length&&(f=c=!1);var h=this.__chain__,p=!!this.__actions__.length,v=i&&!h,_=f&&!p;if(!i&&c){t=_?t:new Dt(this);var g=n.apply(t,o);return g.__actions__.push({func:tf,args:[l],thisArg:X}),new Y(g,h)}return v&&_?n.apply(this,o):(g=this.thru(l),v?e?g.value()[0]:g.value():g)})}),u(["pop","push","shift","sort","splice","unshift"],function(n){var t=_l[n],r=/^(?:push|sort|unshift)$/.test(n)?"tap":"thru",e=/^(?:pop|shift)$/.test(n);K.prototype[n]=function(){var n=arguments;if(e&&!this.__chain__){var u=this.value();return t.apply(wh(u)?u:[],n)}return this[r](function(r){return t.apply(wh(r)?r:[],n)})}}),fe(Dt.prototype,function(n,t){var r=K[t];if(r){var e=r.name+"",u=as[e]||(as[e]=[]);u.push({name:t,func:r})}}),as[ri(X,vn).name]=[{name:"wrapper",func:X}],Dt.prototype.clone=Yt,Dt.prototype.reverse=Qt,Dt.prototype.value=Xt,K.prototype.at=Xs,K.prototype.chain=rf,K.prototype.commit=ef,K.prototype.next=uf,K.prototype.plant=ff,K.prototype.reverse=af,K.prototype.toJSON=K.prototype.valueOf=K.prototype.value=cf,K.prototype.first=K.prototype.head,Bl&&(K.prototype[Bl]=of),K},be=de();"function"==typeof define&&"object"==typeof define.amd&&define.amd?(re._=be,define(function(){return be})):ue?((ue.exports=be)._=be,ee._=be):re._=be}).call(this);
diff --git a/win/options/xml.js b/win/options/xml.js
new file mode 100644
index 0000000..0bff785
--- /dev/null
+++ b/win/options/xml.js
@@ -0,0 +1,89 @@
+/* global _ */
+/* eslint-disable no-unused-vars */
+
+// flattens an object (recursively!), similarly to Array#flatten
+// e.g. flatten({ a: { b: { c: "hello!" } } }); // => "hello!"
+function _flatten(object) {
+ return (_.isPlainObject(object) && _.size(object) === 1) ? _flatten(_.values(object)[0]) : object;
+}
+
+function _parse(xml) {
+ let data = {},
+ isText = xml.nodeType === 3,
+ isElement = xml.nodeType === 1,
+ body = xml.textContent && xml.textContent.trim(),
+ hasChildren = xml.children && xml.children.length,
+ hasAttributes = xml.attributes && xml.attributes.length;
+
+ // if it's text just return it
+ if (isText) {
+ return xml.nodeValue.trim();
+ }
+
+ // if it doesn't have any children or attributes, just return the contents
+ if (!hasChildren && !hasAttributes) {
+ return body;
+ }
+
+ // if it doesn't have children but _does_ have body content, we'll use that
+ if (!hasChildren && body.length) {
+ data.text = body;
+ }
+
+ // if it's an element with attributes, add them to data.attributes
+ if (isElement && hasAttributes) {
+ data.attributes = _.reduce(
+ xml.attributes,
+ (obj, name, id) => {
+ const attr = xml.attributes.item(id);
+ obj[attr.name] = attr.value;
+ return obj;
+ },
+ {}
+ );
+ }
+
+ // recursively call #parse over children, adding results to data
+ _.each(xml.children, child => {
+ const name = child.nodeName;
+
+ // if we've not come across a child with this nodeType, add it as an object
+ // and return here
+ if (!_.has(data, name)) {
+ data[name] = _parse(child);
+ return;
+ }
+
+ // if we've encountered a second instance of the same nodeType, make our
+ // representation of it an array
+ if (!_.isArray(data[name])) {
+ data[name] = [data[name]];
+ }
+
+ // and finally, append the new child
+ data[name].push(_parse(child));
+ });
+
+ // if we can, let's fold some attributes into the body
+ _.each(data.attributes, (value, key) => {
+ if (data[key] != null) {
+ return;
+ }
+ data[key] = value;
+ delete data.attributes[key];
+ });
+
+ // if data.attributes is now empty, get rid of it
+ if (_.isEmpty(data.attributes)) {
+ delete data.attributes;
+ }
+
+ // simplify to reduce number of final leaf nodes and return
+ return _flatten(data);
+}
+
+function parseXML(string) {
+ let xml = new DOMParser().parseFromString(string, 'text/xml');
+
+ return _parse(xml);
+}
diff --git a/win/plugn.js b/win/plugn.js
new file mode 100644
index 0000000..049dda7
--- /dev/null
+++ b/win/plugn.js
@@ -0,0 +1,655 @@
+/* plugn.js (Plugin) - Web to Plex */
+/* global chrome */
+
+let PLUGN_DEVELOPER = false;
+
+let PLUGN_TERMINAL =
+ PLUGN_DEVELOPER?
+ console:
+ { error: m => m, info: m => m, log: m => m, warn: m => m, group: m => m, groupEnd: m => m };
+
+let LAST, LAST_JS, LAST_INSTANCE, LAST_ID, LAST_TYPE, FOUND = {};
+
+let PLUGN_STORAGE = chrome.storage.sync || chrome.storage.local;
+let PLUGN_CONFIGURATION;
+
+function load(name, private) {
+ return JSON.parse((private && sessionStorage? sessionStorage: localStorage).getItem(btoa(name)));
+}
+
+function save(name, data, private) {
+ return (private && sessionStorage? sessionStorage: localStorage).setItem(btoa(name), JSON.stringify(data));
+}
+
+async function Load(name = '') {
+ if(!name)
+ return /* invalid name */;
+
+ name = '~/cache/' + (name.toLowerCase().replace(/\s+/g, '_'));
+
+ return new Promise((resolve, reject) => {
+ function LOAD(DISK) {
+ let data = JSON.parse(DISK[name] || null);
+
+ return resolve(data);
+ }
+
+ PLUGN_STORAGE.get(null, DISK => {
+ if(chrome.runtime.lastError)
+ chrome.storage.local.get(null, LOAD);
+ else
+ LOAD(DISK);
+ });
+ });
+}
+
+async function Save(name = '', data) {
+ if(!name)
+ return /* invalid name */;
+
+ name = '~/cache/' + (name.toLowerCase().replace(/\s+/g, '_'));
+ data = JSON.stringify(data);
+
+ await PLUGN_STORAGE.set({[name]: data}, () => data);
+
+ return name;
+}
+
+function GetConsent(name, builtin) {
+ /* The configuration variable could fail to be initialized */
+ if(!PLUGN_CONFIGURATION)
+ throw 'Configuration not found, exiting prematurely';
+
+ return PLUGN_CONFIGURATION[`${ (builtin? 'builtin': 'plugin') }_${ name }`];
+}
+
+async function GetAuthorization(name) {
+ let authorized = await Load(`has/${ name }`),
+ permissions = await Load(`get/${ name }`),
+ Ausername, Apassword, Atoken,
+ Aapi, Aserver, Aurl, Astorage,
+ Acache, Anormie, Aquality;
+
+ if(!permissions)
+ return {};
+
+ function WriteOff(permission) {
+ if(/^(usernames?)$/i.test(permission))
+ Ausername = true;
+ else if(/^(passwords?)$/i.test(permission))
+ Apassword = true;
+ else if(/^(tokens?)$/i.test(permission))
+ Atoken = true;
+ else if(/^(api|client(?:id)?)$/i.test(permission))
+ Aapi = true;
+ else if(/^(servers?)$/i.test(permission))
+ Aserver = true;
+ else if(/^(url(?:root)?|proxy)$/i.test(permission))
+ Aurl = true;
+ else if(/^(storage)$/i.test(permission))
+ Astorage = true;
+ else if(/^(cache)$/i.test(permission))
+ Acache = true;
+ else if(/^(builtin|plugin)$/i.test(permission))
+ Anormie = true;
+ else if(/^(qualit(?:y|ies))$/i.test(permission))
+ Aquality = true;
+ }
+
+ if(permissions.constructor === Array)
+ for(let permission of permissions)
+ WriteOff(permission);
+ else if(permissions.constructor === Object)
+ for(let permission in permissions)
+ WriteOff(permission);
+
+ return { authorized, Ausername, Apassword, Atoken, Aapi, Aserver, Aurl, Astorage, Acache, Anormie, Aquality };
+}
+
+// get the saved options
+function getConfiguration() {
+ return new Promise((resolve, reject) => {
+ function handleConfiguration(options) {
+ if((!options.plexToken || !options.servers) && !options.IGNORE_PLEX)
+ return reject(new Error('Required options are missing')),
+ null;
+
+ let server, o;
+
+ if(!options.IGNORE_PLEX) {
+ // For now we support only one Plex server, but the options already
+ // allow multiple for easy migration in the future.
+ server = options.servers[0];
+ o = {
+ server: {
+ ...server,
+ // Compatibility for users who have not updated their settings yet.
+ connections: server.connections || [{ uri: server.url }]
+ },
+ ...options
+ };
+
+ options.plexURL = o.plexURL?
+ `${ o.plexURL }web#!/server/${ o.server.id }/`:
+ `https://app.plex.tv/web/app#!/server/${ o.server.id }/`;
+ } else {
+ o = options;
+ }
+
+ if(o.couchpotatoBasicAuthUsername)
+ o.couchpotatoBasicAuth = {
+ username: o.couchpotatoBasicAuthUsername,
+ password: o.couchpotatoBasicAuthPassword
+ };
+
+ // TODO: stupid copy/pasta
+ if(o.watcherBasicAuthUsername)
+ o.watcherBasicAuth = {
+ username: o.watcherBasicAuthUsername,
+ password: o.watcherBasicAuthPassword
+ };
+
+ if(o.radarrBasicAuthUsername)
+ o.radarrBasicAuth = {
+ username: o.radarrBasicAuthUsername,
+ password: o.radarrBasicAuthPassword
+ };
+
+ if(o.sonarrBasicAuthUsername)
+ o.sonarrBasicAuth = {
+ username: o.sonarrBasicAuthUsername,
+ password: o.sonarrBasicAuthPassword
+ };
+
+ if(o.usingOmbi && o.ombiURLRoot && o.ombiToken) {
+ o.ombiURL = o.ombiURLRoot;
+ } else {
+ delete o.ombiURL; // prevent variable ghosting
+ }
+
+ if(o.usingCouchPotato && o.couchpotatoURLRoot && o.couchpotatoToken) {
+ o.couchpotatoURL = `${ items.couchpotatoURLRoot }/api/${encodeURIComponent(o.couchpotatoToken)}`;
+ } else {
+ delete o.couchpotatoURL; // prevent variable ghosting
+ }
+
+ if(o.usingWatcher && o.watcherURLRoot && o.watcherToken) {
+ o.watcherURL = o.watcherURLRoot;
+ } else {
+ delete o.watcherURL; // prevent variable ghosting
+ }
+
+ if(o.usingRadarr && o.radarrURLRoot && o.radarrToken) {
+ o.radarrURL = o.radarrURLRoot;
+ } else {
+ delete o.radarrURL; // prevent variable ghosting
+ }
+
+ if(o.usingSonarr && o.sonarrURLRoot && o.sonarrToken) {
+ o.sonarrURL = o.sonarrURLRoot;
+ } else {
+ delete o.sonarrURL; // prevent variable ghosting
+ }
+
+ resolve(o);
+ }
+
+ PLUGN_STORAGE.get(null, options => {
+ if(chrome.runtime.lastError)
+ chrome.storage.local.get(null, handleConfiguration);
+ else
+ handleConfiguration(options);
+ });
+ });
+}
+
+// self explanatory, returns an object; sets the configuration variable
+function parseConfiguration() {
+ return getConfiguration().then(options => {
+ PLUGN_CONFIGURATION = options;
+
+ if((PLUGN_DEVELOPER = options.DeveloperMode) && !parseConfiguration.gotConfig) {
+ parseConfiguration.gotConfig = true;
+ PLUGN_TERMINAL =
+ PLUGN_DEVELOPER?
+ console:
+ { error: m => m, info: m => m, log: m => m, warn: m => m, group: m => m, groupEnd: m => m };
+
+ PLUGN_TERMINAL.warn(`PLUGN_DEVELOPER: ${PLUGN_DEVELOPER}`);
+ }
+
+ return options;
+ }, error => { throw error });
+}
+
+chrome.storage.onChanged.addListener(async(changes, namespace) => {
+ await parseConfiguration();
+});
+
+(async() => {
+ await parseConfiguration();
+})();
+
+function RandomName(length = 16, symbol = '') {
+ let values = [];
+
+ window.crypto.getRandomValues(new Uint32Array(length)).forEach((value, index, array) => values.push(value.toString(36)));
+
+ return values.join(symbol).replace(/^[^a-z]+/i, '');
+};
+
+function TLDHost(host) {
+ return host.replace(/^(ww\w+|\w{2})\./, '');
+}
+
+async function prepare({ code, alias, type, allowed, url }) {
+
+ let DATE = (new Date),
+ YEAR = DATE.getFullYear(),
+ MONT = DATE.getMonth(),
+ DAY = DATE.getDate();
+
+ let name = (!PLUGN_DEVELOPER? instance: `top.${ instance }`), // makes debugging easier
+ topmost = !/^top\./.test(name),
+ Type = type.replace(/^\w/, ($0, $$, $_) => $0.toUpperCase());
+
+ let org = url.origin,
+ ali = TLDHost(url.host);
+
+ let { authorized, ...A } = await GetAuthorization(alias);
+
+return `/* ${ type } (${ (!PLUGN_DEVELOPER? 'on':'off') }line) - "${ url.href }" @ ${ DATE } */
+
+${ topmost? 'var ': '' }${ name } = (${ name } || (${ name }$ = $ => {
+'use strict';
+
+let DATE = (new Date),
+ YEAR = ${YEAR},
+ MONT = ${MONT},
+ DAY = ${DAY};
+
+/* Required permissions */
+if(${ allowed } === false)
+ return '';
+if(${ authorized } === false)
+ return '';
+${
+(() => {
+ let o = [];
+
+ for(let a in A)
+ o.push(
+`if(${ A[a] } === false)
+ return '<${ a.slice(1) }>';
+`
+ );
+
+ return o.join('');
+})()
+}
+/* Start Injected ${ Type } */
+${
+ code
+ .replace(/\/\/+\s*"([^\"\n\f\r\v]+?)"\s*requires?\:?\s*(.+)/i, ($0, $1, $2, $$, $_) =>
+ `;(async() => await Require("${ $2 }", "${ alias }", "${ $1 }", "${ instance }"))();`
+ )
+}
+/* End Injected ${ Type } */
+
+let ${ Type }ReadyState;
+
+top.addEventListener('popstate', ${ type }.init);
+top.addEventListener('pushstate-changed', ${ type }.init);
+
+return (
+ ${ type }.RegExp = RegExp(
+ ${ type }.url
+ .replace(/^\\*\\:/,'\\\\w{3,}:')
+ // *://
+ .replace(/\\*\\./g,'(?:[^\\\\.]+\\\\.)?')
+ // *.
+ .replace(/\\.\\*/g,'(?:\\\\.[^\\\\/\\\\.]+)?')
+ // .*
+ .replace(/([\\/\\?\\&\\#])\\*/g,'\\\\$1[^$]*')
+ // /* OR ?* OR &* OR #*
+ , 'i')
+).test
+/* URL matches pattern */
+(location.href)?
+ /* Injected ${ type } is properly structured */
+ (typeof ${ type }.init == "function")?
+ /* Injected ${ type } has the "ready" property */
+ ${ type }.ready?
+ /* Injected ${ type } is ready */
+ (${ Type }ReadyState =
+ /* "ready" is an async function */
+ ${ type }.ready.constructor.name == 'AsyncFunction'?
+ ${ type }.ready():
+ /* "ready" is a sync (normal) function */
+ ${ type }.ready()
+ )?
+ ${ type }.init( ${ Type }ReadyState ):
+ /* Injected ${ type } isn't ready */
+ (${ type }.timeout || 1000):
+ /* Injected ${ type } doesn't have the "ready" property */
+ ${ type }.init():
+ /* Injected ${ type } isn't properly structured */
+ (console.warn("The ${ type } (${ alias }) is incorrectly structured. Could not find required function ${ type }.init"), -1):
+/* URL doesn't match pattern */
+(console.warn("The domain '${ org }' (" + location.href + ") does not match the domain pattern '" + ${ type }.url + "' (" + ${ type }.RegExp + ")"), -1);
+})(document.queryBy));
+
+console.log('[${ name.replace(/^(top\.)?(\w{7}).*$/i, '$1$2') }]', ${ name });
+
+top.onlocationchange = (event) => chrome.runtime.sendMessage({ type: '$INIT$', options: { ${ type }: '${ alias }' } });
+
+;${ name };`
+}
+
+let handle = async(results, tabID, instance, script, type) => {
+ let InstanceWarning = `[${ type.toUpperCase() }:${ script }] Instance failed to execute @${ tabID }#${ instance }`,
+ InstanceType = type;
+
+ results = await results;
+
+ /* Always display a pretty button */
+ chrome.tabs.insertCSS(tabID, { file: 'sites/common.css' });
+
+ if((!results || !results[0] || !instance) && !FOUND[instance])
+ try {
+ instance = RandomName();
+ tabchange([ TAB ]);
+ return;
+ } catch(error) {
+ return PLUGN_TERMINAL.warn(InstanceWarning);
+ }
+
+ let data = await results[0];
+
+ if(typeof data == 'number') {
+ if(handle.timeout)
+ return /* already running */;
+ if(data < 0)
+ return chrome.tabs.sendMessage(tabID, { data, instance, [InstanceType.toLowerCase()]: script, instance_type: InstanceType, type: 'NO_RENDER' })
+ /* stop execution and timeouts/intervals */;
+
+ return handle.timeout = setTimeout(() => { let { request, sender, callback } = (processMessage.properties || {}); handle.timeout = null; processMessage(request, sender, callback) }, data);
+ } else if(typeof data == 'string') {
+ let R = RegExp;
+
+ if(/^<([^<>]+)>$/.test(data))
+ return PLUGN_TERMINAL.warn(`The instance requires the "${ R.$1 }" permission: ${ instance }`);
+
+ data.replace(/^([^]+?)\s*\((\d{4})\):([\w\-]+)$/);
+
+ let title = R.$1,
+ year = R.$2,
+ type = R.$3;
+
+ data = { type, title, year };
+ }
+
+ if(typeof data == 'number')
+ return setTimeout(() => { let { request, sender, callback } = (processMessage.properties || {}); processMessage(request, sender, callback) }, data);
+ if(typeof data != 'object')
+ return /* setTimeout */;
+
+ try {
+ if(data instanceof Array) {
+ data = data.filter(d => d);
+
+ if(data.length > 1) {
+ chrome.tabs.sendMessage(tabID, { data, instance, [InstanceType.toLowerCase()]: script, instance_type: InstanceType, type: 'POPULATE' });
+ return /* done */;
+ }
+
+ /* the array is too small to parse, set it as a single item */
+ data = data[0];
+ }
+
+ let { type, title, year } = data;
+
+ title = title
+ .replace(/[\u2010-\u2015]/g, '-') // fancy hyphen
+ .replace(/[\u201a\u275f]/g, ',') // fancy comma
+ .replace(/[\u2018\u2019\u201b\u275b\u275c]/g, "'") // fancy apostrophe
+ .replace(/[\u201c-\u201f\u275d\u275e]/g, '"'); // fancy quotation marks
+ year = +year;
+
+ data = { ...data, type, title, year };
+
+ chrome.tabs.sendMessage(tabID, { data, instance, [InstanceType.toLowerCase()]: script, instance_type: InstanceType, type: 'POPULATE' });
+ } catch(error) {
+ throw new Error(InstanceWarning + ' - ' + String(error));
+ }
+};
+
+let running = [], instance = RandomName(), TAB, cache = {};
+
+/* Handle script/plugin events */
+let tabchange = async tabs => {
+ let tab = tabs[0];
+
+ if(!tab || FOUND[instance]) return;
+
+ TAB = tab;
+
+ let id = tab.id,
+ url = tab.url,
+ org, ali, js,
+ type, cached,
+ allowed;
+
+ if(
+ !url
+ || /^(?:chrome|debugger|view-source)/i.test(url)
+ // || (!!~running.indexOf(id) && !!~running.indexOf(instance))
+ )
+ return /*
+ Stop if:
+ a) There isn't a url
+ b) The url is a chrome url
+ c) The tab AND instance are accounted for
+ */;
+
+ url = new URL(url);
+ org = url.origin;
+ ali = TLDHost(url.host);
+ type = (load(`builtin:${ ali }`) + '') == 'true'? 'script': 'plugin';
+ js = load(`${ type }:${ ali }`);
+ code = cache[ali];
+ allowed = await GetConsent(ali, type == 'script');
+
+ if(!allowed || !js) return;
+
+ if(code) {
+ chrome.tabs.executeScript(id, { file: 'helpers.js' }, () => {
+ // Sorry, but the instance needs to be callable multiple times
+ chrome.tabs.executeScript(id, { code }, results => handle(results, id, instance, js, type));
+ });
+
+ return setTimeout(() => cache = {}, 1e6);
+ }
+
+ let file = (PLUGN_DEVELOPER)?
+ (type === 'script')?
+ chrome.runtime.getURL(`cloud/${ js }.js`):
+ chrome.runtime.getURL(`cloud/plugin/${ js }.js`):
+ `https://ephellon.github.io/web.to.plex/${ type }s/${ js }.js`;
+
+ await fetch(file, { mode: 'cors' })
+ .then(response => response.text())
+ .then(async code => {
+ await chrome.tabs.executeScript(id, { file: 'helpers.js' }, async() => {
+ // Sorry, but the instance needs to be callable multiple times
+ await chrome.tabs.executeScript(id, {
+ code: (LAST = cache[ali] = await prepare({ code, alias: js, type, allowed, url })),
+ }, results => handle(results, LAST_ID = id, LAST_INSTANCE = instance, LAST_JS = js, LAST_TYPE = type))
+ })
+ })
+ .then(() => running.push(id, instance))
+ .catch(error => { throw error });
+};
+
+// listen for message event
+let processMessage;
+
+chrome.runtime.onMessage.addListener(processMessage = async(request = {}, sender, callback = () => {}) => {
+ let { options } = request,
+ tab = TAB || {},
+ { id, url, href } = tab,
+ org;
+
+ processMessage.properties = { request, sender, callback };
+
+ if(
+ !url
+ || /^(?:chrome|debugger|view-source)/i.test(url)
+ // || (!!~running.indexOf(id) && !!~running.indexOf(instance))
+ )
+ return callback(null) /*
+ Stop if:
+ a) There isn't a url
+ b) The url is a chrome url
+ c) The tab AND instance are accounted for
+ */;
+
+ url = new URL(url);
+ org = url.origin;
+
+ let name = (!PLUGN_DEVELOPER? instance: `top.${ instance }`), // makes debugging easier
+ topmost = !/^top\./.test(name);
+
+ if(request.options) {
+ let { type } = request,
+ { plugin, script } = options,
+ _type = type.toLowerCase(),
+ allowed;
+
+ type = type.toUpperCase();
+
+ let file = (PLUGN_DEVELOPER)?
+ (_type === 'script')?
+ chrome.runtime.getURL(`cloud/${ script }.js`):
+ chrome.runtime.getURL(`cloud/plugin/${ plugin }.js`):
+ `https://ephellon.github.io/web.to.plex/${ _type }s/${ options[_type] }.js`;
+
+ let { authorized, ...A } = await GetAuthorization(options[_type]);
+
+ try {
+ switch(type) {
+ case 'PLUGIN':
+ allowed = await GetConsent(plugin, false);
+
+ await fetch(file, { mode: 'cors' })
+ .then(response => response.text())
+ .then(async code => {
+ await chrome.tabs.executeScript(id, { file: 'helpers.js' }, async() => {
+ // Sorry, but the instance needs to be callable multiple times
+ await chrome.tabs.executeScript(id, {
+ code: (LAST = cache[plugin] = await prepare({ code, alias: plugin, type: 'plugin', allowed, url }))
+ }, results => handle(results, LAST_ID = id, LAST_INSTANCE = instance, LAST_JS = plugin, LAST_TYPE = type))
+ })
+ })
+ .then(() => running.push(id, instance))
+ .catch(error => { throw error });
+ break;
+
+ case 'SCRIPT':
+ allowed = await GetConsent(script, true);
+
+ await fetch(file, { mode: 'cors' })
+ .then(response => response.text())
+ .then(async code => {
+ await chrome.tabs.executeScript(id, { file: 'helpers.js' }, async() => {
+ // Sorry, but the instance needs to be callable multiple times
+ await chrome.tabs.executeScript(id, {
+ code: (LAST = cache[script] = await prepare({ code, alias: script, type: 'script', allowed, url }))
+ }, results => handle(results, LAST_ID = id, LAST_INSTANCE = instance, LAST_JS = script, LAST_TYPE = type))
+ })
+ })
+ .then(() => running.push(id, instance))
+ .catch(error => { throw error });
+ break;
+
+ // Soft reset (button reset)
+ case '_INIT_':
+ chrome.tabs.executeScript(id, { code: LAST }, results => handle(results, LAST_ID, LAST_INSTANCE, LAST_JS, LAST_TYPE));
+ break;
+
+ // Hard reset (program reset)
+ case '$INIT$':
+ let t = type.toLowerCase(),
+ data = {};
+
+ chrome.tabs.sendMessage(tab.id, { data, instance, [t]: script, instance_type: t, type: 'INITIALIZE' });
+ // chrome.tabs.getCurrent(tab => {
+ // instance = RandomName();
+ //
+ // setTimeout(() => tabchange([ tab ]), 5000);
+ // });
+ break;
+
+ case 'FOUND':
+ FOUND[request.instance] = request.found;
+ break;
+
+ case 'GRANT_PERMISSION':
+ await Save(`has/${ options[_type] }`, options.allowed);
+ await Save(`get/${ options[_type] }`, options.permissions);
+ break;
+
+ case 'SEARCH_PLEX':
+ case 'VIEW_COUCHPOTATO':
+ case 'PUSH_COUCHPOTATO':
+ case 'PUSH_RADARR':
+ case 'PUSH_SONARR':
+ case 'PUSH_MEDUSA':
+ case 'PUSH_WATCHER':
+ case 'PUSH_OMBI':
+ case 'PUSH_SICKBEARD':
+ case 'OPEN_OPTIONS':
+ case 'SEARCH_FOR':
+ case 'SAVE_AS':
+ case 'DOWNLOAD_FILE':
+ case 'UPDATE_CONFIGURATION':
+ /* Meant to be handled by background.js */
+ break;
+
+ default:
+ PLUGN_TERMINAL.warn(`Unable to find type "${ type }"`);
+ instance = RandomName();
+ return false;
+ }
+
+ return true;
+ } catch(error) {
+ PLUGN_TERMINAL.error(error);
+ // callback(String(error));
+ return false;
+ }
+ } else {
+ return true;
+ }
+});
+
+// this doesn't actually work...
+// chrome.tabs.onActiveChanged.addListener(tabchange);
+
+// workaround for the above
+chrome.tabs.onActivated.addListener(change => {
+ instance = RandomName();
+
+ chrome.tabs.get(change.tabId, tab => tabchange([ tab ]));
+});
+
+let refresh;
+
+chrome.tabs.onUpdated.addListener(refresh = (ID, change, tab) => {
+ instance = RandomName();
+
+ if(change.status == 'complete' && !tab.discarded)
+ tabchange([ tab ]);
+ else if(!tab.discarded)
+ setTimeout(() => refresh(ID, change, tab), 1000);
+});
diff --git a/win/popup/index.css b/win/popup/index.css
new file mode 100644
index 0000000..d137406
--- /dev/null
+++ b/win/popup/index.css
@@ -0,0 +1,311 @@
+html, body {
+ height: 625px !important;
+ width: 625px !important;
+}
+
+body {
+ background: url(../img/noise.png) fixed, url(../img/background.png) no-repeat fixed center/cover, #3f4245 !important;
+ color: #333 !important;
+ font-family: Open Sans Regular, Helvetica Neue, Helvetica, Arial, sans-serif, system;
+ font-size: 1.25em !important;
+ flex-grow: 1 !important;
+ padding: 1px !important;
+ overflow: hidden !important;
+ position: absolute !important;
+}
+
+a {
+ text-decoration: none !important;
+ cursor: pointer !important;
+ margin: 0 !important;
+}
+
+img {
+ vertical-align: middle !important;
+ padding: 0 1em !important;
+}
+
+label {
+ color: #eee !important;
+ cursor: inherit !important;
+ font-weight: 400 !important;
+ display: inline-block !important;
+ margin-bottom: 5px;
+ display: block !important;
+ position: relative !important;
+/*
+ height: 165px !important;
+ width: 170px !important;
+*/
+}
+
+table, footer {
+ height: 35% !important;
+ width: 100% !important;
+ font-size: 1em !important;
+ overflow: auto !important;
+ color: #fff !important;
+ font-weight: 400 !important;
+ padding-bottom: 1em !important;
+}
+
+tr, footer > * {
+ box-shadow: none !important;
+ height: calc(33% - 25px);
+ width: calc(100% - 25px);
+ padding: 0 !important;
+}
+
+td, footer > * > * {
+ padding: 15px !important;
+ line-height: 1.33 !important;
+ border-radius: 3px;
+ cursor: pointer !important;
+ text-transform: uppercase;
+ border: 0;
+ background: rgba(255, 255, 255, 0.3);
+ margin-bottom: 0;
+ vertical-align: middle;
+ white-space: nowrap;
+ user-select: none;
+ height: calc(100% - 30px);
+ width: calc(100% - 30px);
+ transition: box-shadow 0.5s !important;
+ display: table-cell !important;
+}
+
+#movieo:hover {
+ box-shadow: 0 10px 128px inset #5DBCD4;
+}
+
+#imdb:hover {
+ box-shadow: 0 10px 128px inset #E0AB00;
+}
+
+#trakt:hover {
+ box-shadow: 0 10px 128px inset #ED2224;
+}
+
+#letterboxd:hover {
+ box-shadow: 0 10px 128px inset #66CC33;
+}
+
+#flenix:hover {
+ box-shadow: 0 10px 128px inset #EC164F;
+}
+
+#tv-maze:hover {
+ box-shadow: 0 10px 128px inset #6EC4BA;
+}
+
+#tvdb:hover {
+ box-shadow: 0 10px 128px inset #1C7E3E;
+}
+
+#tmdb:hover {
+ box-shadow: 0 10px 128px inset #01D277;
+}
+
+#vrv:hover {
+ box-shadow: 0 10px 128px inset #FFDD00;
+}
+
+#hulu:hover {
+ box-shadow: 0 10px 128px inset #66AA33;
+}
+
+#netflix:hover {
+ box-shadow: 0 10px 128px inset #E50914;
+}
+
+#google:hover {
+ box-shadow: 0 10px 128px inset #EEEEEE;
+}
+
+#itunes:hover {
+ box-shadow: 0 10px 128px inset #EEEEEE;
+}
+
+#metacritic:hover {
+ box-shadow: 0 10px 128px inset #001B36;
+}
+
+#fandango:hover {
+ box-shadow: 0 10px 128px inset #FF7300;
+}
+
+#amazon:hover {
+ box-shadow: 0 10px 128px inset #FF9900;
+}
+
+#vudu:hover {
+ box-shadow: 0 10px 128px inset #027FC5;
+}
+
+#verizon:hover {
+ box-shadow: 0 10px 128px inset #E10000;
+}
+
+#couch-potato:hover {
+ box-shadow: 0 10px 128px inset #ECB501;
+}
+
+#rotten-tomatoes:hover {
+ box-shadow: 0 10px 128px inset #FA3008;
+}
+
+#showrss:hover {
+ box-shadow: 0 10px 128px inset #6A8592;
+}
+
+#vumoo:hover {
+ box-shadow: 0 10px 128px inset #DD1B2F;
+}
+
+#shana-project:hover {
+ box-shadow: 0 10px 128px inset #FF0000;
+}
+
+#youtube:hover {
+ box-shadow: 0 10px 128px inset #FF0000;
+}
+
+#flickmetrix:hover {
+ box-shadow: 0 10px 128px inset #7A314E;
+}
+
+#justwatch:hover {
+ box-shadow: 0 10px 128px inset #0E202C;
+}
+
+#moviemeter:hover {
+ box-shadow: 0 10px 128px inset #000000;
+}
+
+#allocine:hover {
+ box-shadow: 0 10px 128px inset #222222;
+}
+
+#gostream:hover {
+ box-shadow: 0 10px 128px inset #028CC9;
+}
+
+#tubi:hover {
+ box-shadow: 0 10px 128px inset #26262D;
+}
+
+#webtoplex:hover {
+ box-shadow: 0 10px 128px inset #CC7B19;
+}
+
+#local-plex:hover {
+ box-shadow: 0 10px 128px inset #F9BD03;
+}
+
+#local-watcher:hover {
+ box-shadow: 0 10px 128px inset #EA554E;
+}
+
+#local-radarr:hover {
+ box-shadow: 0 10px 128px inset #FFC230;
+}
+
+#local-sonarr:hover {
+ box-shadow: 0 10px 128px inset #36C6F4;
+}
+
+#local-couchpotato:hover {
+ box-shadow: 0 10px 128px inset #D20000;
+}
+
+#local-ombi:hover {
+ box-shadow: 0 10px 128px inset #E48F34;
+}
+
+#local-medusa:hover {
+ box-shadow: 0 10px 128px inset #26B043;
+}
+
+#local-sickBeard:hover {
+ box-shadow: 0 10px 128px inset #296737;
+}
+
+[save-file]:after, [cost-cash-low]:after, [cost-cash-med]:after, [cost-cash-hig]:after {
+ content: "____";
+ color: transparent;
+ float: right;
+ width: 3em;
+ height: 3em;
+ margin-top: -8.5em;
+ margin-right: -1em;
+}
+
+[pop-ups] label:after {
+ content: " \1F92C";
+ float: right;
+}
+
+[local] label:after {
+ content: " \1F5A5";
+ float: right;
+}
+
+[is-slow] label:after {
+ content: " \23f3";
+ float: right;
+}
+
+[is-shy] label:after, [is-dead] label:after {
+ content: " \1f910";
+ float: right;
+}
+
+[save-file]:after {
+ background: url("../img/48.png") no-repeat center;
+}
+
+[not-safe] label:after {
+ content: " \1F527";
+ float: right;
+}
+
+/* $1 - $10 */
+[cost-cash-low]:after {
+ background: url("../img/$48.png") no-repeat center;
+}
+
+/* $11 - $30 */
+[cost-cash-med]:after {
+ background: url("../img/$$48.png") no-repeat center;
+}
+
+/* $31+ */
+[cost-cash-hig]:after {
+ background: url("../img/$$$48.png") no-repeat center;
+}
+
+[disabled], [disabled]:hover {
+/* box-shadow: 0 10px 128px inset #000 !important;*/
+ opacity: 0.5 !important;
+}
+
+[disabled] label:after {
+ content: " \23f3" !important;
+ float: right;
+}
+
+*::-webkit-scrollbar {
+ width: 10px;
+}
+
+*::-webkit-scrollbar-thumb {
+ min-height: 50px;
+ background: rgba(255, 255, 255, 0.15);
+ border: 2px solid rgba(0, 0, 0, 0);
+ border-radius: 8px;
+ background-clip: padding-box;
+}
+
+*::-webkit-scrollbar-track {
+ background: url(../img/noise.png) repeat, #3f4245 !important;
+}
diff --git a/win/popup/index.html b/win/popup/index.html
new file mode 100644
index 0000000..ca9d0ae
--- /dev/null
+++ b/win/popup/index.html
@@ -0,0 +1,250 @@
+
+
+
+
+
+ Web to Plex
+
+
+
+
+
+
+
+
+
+
+
diff --git a/win/popup/index.js b/win/popup/index.js
new file mode 100644
index 0000000..807203f
--- /dev/null
+++ b/win/popup/index.js
@@ -0,0 +1,92 @@
+function load(name) {
+ return JSON.parse(localStorage.getItem(btoa(name)));
+}
+
+function save(name, data) {
+ return localStorage.setItem(btoa(name), JSON.stringify(data));
+}
+
+let table = document.body.querySelector('table'),
+ array = load('URLs');
+
+if(array && array.length) {
+ let strings = [],
+ compiled = [],
+ object = {},
+ width = 3;
+
+ for(let count = 0, length = Math.ceil(array.length / width); count < length;)
+ for(let index = width * count++, name, url; index < count * width; index++)
+ object[name = array[index]] = (!/^(null|undefined)?$/.test( url = load(`${ name }.url`) || '' ))?
+`
+
+
+ ${ name }
+
+ `: null;
+
+ for(let index = 0, length = array.length, string; index < length; index++)
+ if(string = object[array[index]])
+ compiled.push(string);
+
+ for(let index = 0, length = compiled.length, string = ''; index <= length; index++) {
+ if((index > 0 && index % 3 == 0) || index >= length)
+ strings.push(string),
+ string = '';
+ if(index < length)
+ string += compiled[index];
+ }
+
+ let html = '';
+
+ strings.map(string =>
+ html +=
+`
+ ${ string }
+ `
+ );
+
+ table.innerHTML = html + table.innerHTML;
+}
+
+document.body.onload = function() {
+ let messages = {
+ "and": "{:{*}}",
+ "disabled": "Not yet implemented",
+ "is-shy": "Can only be accessed via: {*}",
+ "is-slow": "Resource intensive (loads slowly)",
+ "is-dead": "Isn't meant to show the Web to Plex button",
+ "local": "Opens a link to ^{*}",
+ "not-safe": "Updated irregularly, may drop support",
+ "pop-ups": "Contains annoying/intrusive ads and/or pop-ups",
+ "save-file": "Uses {*} before using your manager(s)",
+ // $0.99 one time; $0.99 - $9.99/mon
+ "cost-cash-low": "At least {*} (fair)",
+ // $9.99 one time; $9.99 - $29.99/mon
+ "cost-cash-med": "At least {*} (pricy)",
+ // $29.99 one time; $29.99 - $99.99/mon
+ "cost-cash-hig": "At least {*} (expensive)"
+ },
+ parse = (string, attribute, element) => {
+ return string
+ .replace(/\{\$\}/g, element.title)
+ .replace(/\{\*\}/g, element.getAttribute(attribute))
+ .replace(/\{\:([\w\- ]+)\}/g, ($0, $1, $$, $_) =>
+ $1.split(' ').map($1 => parse(element.getAttribute($1), $1, element))
+ )
+ .replace(/\^([a-z])/gi, ($0, $1, $$, $_) => $1.toUpperCase());
+ },
+ selectors = [];
+
+ for(let key in messages)
+ selectors.push(`[${ key }]`);
+
+ let elements = document.querySelectorAll(selectors.join(','));
+
+ for(let element, index = 0, length = elements.length; index < length; index++) {
+ let number = 1;
+ for(let attribute in messages)
+ if(attribute in (element = elements[index]).attributes)
+ element.title += `\n${(number++)}) ${ parse(messages[attribute], attribute, element) }.`;
+ }
+}
diff --git a/win/sites/__layout__.js b/win/sites/__layout__.js
new file mode 100644
index 0000000..9831075
--- /dev/null
+++ b/win/sites/__layout__.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: '< Page Alias >' }))();
diff --git a/win/sites/__test__.js b/win/sites/__test__.js
new file mode 100644
index 0000000..68ee2d5
--- /dev/null
+++ b/win/sites/__test__.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: '__test__' }))();
diff --git a/win/sites/allocine/index.css b/win/sites/allocine/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/allocine/index.js b/win/sites/allocine/index.js
new file mode 100644
index 0000000..850536c
--- /dev/null
+++ b/win/sites/allocine/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'allocine' }))();
diff --git a/win/sites/amazon/index.css b/win/sites/amazon/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/amazon/index.js b/win/sites/amazon/index.js
new file mode 100644
index 0000000..70796df
--- /dev/null
+++ b/win/sites/amazon/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'amazon' }))();
diff --git a/win/sites/common.css b/win/sites/common.css
new file mode 100644
index 0000000..c8a5e0f
--- /dev/null
+++ b/win/sites/common.css
@@ -0,0 +1,625 @@
+/** Common CSS
+ * Web to Plex
+ */
+ /* Basic/Global Styling */
+ [class*="web-to-plex"]::-webkit-scrollbar, [class*="web-to-plex"]::-moz-scrollbar {
+ width: 10px !important;
+ }
+
+ [class*="web-to-plex"]::-webkit-scrollbar-thumb, [class*="web-to-plex"]::-moz-scrollbar-thumb {
+ min-height: 50px !important;
+ background: rgba(255, 255, 255, 0.15) !important;
+ border: 2px solid rgba(0, 0, 0, 0) !important;
+ border-radius: 8px !important;
+ background-clip: padding-box !important;
+ }
+
+ [class*="web-to-plex"]::-webkit-scrollbar-track, [class*="web-to-plex"]::-moz-scrollbar-track {
+ background: #0000 !important;
+ }
+
+ [class*="web-to-plex"]::-webkit-input-placeholder, [class*="web-to-plex"]::-moz-placeholder, [class*="web-to-plex"]:-moz-placeholder {
+ color: #999 !important;
+ }
+
+ [class*="web-to-plex"] input[type="text"], [class*="web-to-plex"] input[type="password"], [class*="web-to-plex"] select {
+ width: 30vw !important;
+ line-height: 1.5em !important;
+ transition: background 0.2s !important;
+ display: block !important;
+ height: 38px !important;
+ padding: 6px 12px !important;
+ font-size: 16px !important;
+ color: #eee !important;
+ vertical-align: middle;
+ background: rgba(255, 255, 255, 0.25) !important;
+ border: 3px solid rgba(0, 0, 0, 0) !important;
+ border-radius: 3px !important;
+ font-family: inherit !important;
+ margin: 0 !important;
+ }
+
+ [class*="web-to-plex"] select {
+ margin-left: 10px !important;
+ font-size: 16px !important;
+ line-height: inherit !important;
+ text-transform: none !important;
+ }
+
+ [class*="web-to-plex"] option {
+ background: #3f4245 !important;
+ }
+
+ [class*="web-to-plex"] button {
+ padding: 10px 18px !important;
+ font-size: 16px !important;
+ line-height: 1.33 !important;
+ border-radius: 3px !important;
+ font-family: inherit !important;
+ text-transform: uppercase !important;
+ border: 0 !important;
+ box-shadow: none !important;
+ position: relative !important;
+ overflow: hidden !important;
+ color: #fff;
+ background: #cc7b19;
+ margin-bottom: 0 !important;
+ font-weight: 400 !important;
+ vertical-align: middle;
+ cursor: pointer !important;
+ white-space: nowrap;
+ user-select: none;
+ transition: all 0.1s !important;
+ }
+
+ [class*="web-to-plex"] button:hover {
+ background: #e59029;
+ }
+
+ [class*="web-to-plex"] input::placeholder, [class*="web-to-plex"] input:placeholder {
+ color: #999 !important;
+ }
+
+ [class*="web-to-plex"][disabled], [class*="web-to-plex"] [disabled] {
+ cursor: not-allowed !important;
+ color: #909090EE !important;
+ }
+
+/* Web to Plex notifications */
+.web-to-plex-notification {
+ background: #F45A26 !important;
+ border-radius: 4px !important;
+ color: #FFF !important;
+ cursor: pointer !important;
+ display: block !important;
+ font-family: arial, verdana, sans-serif !important;
+ font-size: 20px !important;
+ text-align: center !important;
+
+ position: fixed !important;
+ left: 50% !important;
+ margin-left: -175px !important;
+ padding: 10px !important;
+ top: 80px !important;
+
+ width: 350px !important;
+ z-index: 999999999 !important;
+}
+
+/* Web to Plex general information notifications */
+.web-to-plex-notification.info {
+ background: #666 !important;
+}
+
+/* Web to Plex update notifications */
+.web-to-plex-notification.update {
+ background: #2A2AFF !important;
+}
+
+/* Web to Plex success notifications */
+.web-to-plex-notification.success {
+ background: #03BDF9 !important;
+}
+
+/* Web to Plex error/warning notifications */
+.web-to-plex-notification.warning, .web-to-plex-notification.error {
+ background: #FF2A2A !important;
+}
+
+/* Web to Plex prompts */
+.web-to-plex-prompt {
+ background: #0008 !important;
+ box-sizing: border-box !important;
+ color: #eee !important;
+ display: block !important;
+ font-family: Open Sans Regular, Helvetica Neue, Helvetica, Arial, sans-serif !important;
+ font-size: 14px !important;
+ line-height: 24px !important;
+ overflow: auto !important;
+
+ height: 100% !important;
+ width: 100% !important;
+
+ bottom: 0 !important;
+ left: 0 !important;
+ right: 0 !important;
+ top: 0 !important;
+ position: fixed !important;
+ z-index: 99999999 !important;
+}
+
+.web-to-plex-prompt-body {
+ background: #282828;
+ box-shadow: 0 5px 15px #0008 !important;
+ display: block !important;
+
+ left: 20% !important;
+ top: 5% !important;
+ padding-top: 10px !important;
+ padding-bottom: 70px !important;
+ position: relative !important;
+
+ height: 60% !important;
+ width: 60% !important;
+}
+
+.web-to-plex-prompt-header, .web-to-plex-prompt-footer {
+ background: #32323240 !important;
+ border: 1px solid #0000 !important;
+ box-sizing: border-box !important;
+ color: #eee !important;
+ font: inherit !important;
+ font-size: 2em !important;
+ line-height: initial !important;
+ text-size-adjust: 100% !important;
+
+ margin-top: 0 !important;
+ padding: 15px 20px !important;
+ position: absolute !important;
+
+ height: 65px !important;
+ width: 100% !important;
+
+ -webkit-tap-highlight-color: #0000;
+}
+
+.web-to-plex-prompt-header {
+ border-bottom-color: #222 !important;
+ border-bottom-width: 1px !important;
+ border-top-left-radius: 3px !important;
+ border-top-right-radius: 3px !important;
+ overflow: hidden;
+ text-align: left !important;
+ text-overflow: ellpsis;
+ white-space: nowrap;
+
+ top: 0 !important;
+}
+
+.web-to-plex-prompt-options {
+ display: block !important;
+ overflow-x: hidden !important;
+ overflow-y: auto !important;
+
+ padding: 12px !important;
+ position: relative !important;
+ top: 65px !important;
+
+ max-height: calc(100% - 65px) !important;
+}
+
+.web-to-plex-prompt-option {
+ background: #32323240 !important;
+ border: 1px solid #202020 !important;
+ border-radius: 3px !important;
+ color: #999 !important;
+ display: block !important;
+ text-align: left !important;
+
+ margin-bottom: 10px !important;
+ padding: 10px !important;
+
+ min-height: 20px !important;
+}
+
+.web-to-plex-prompt-option.mutable {
+ max-width: 60% !important;
+}
+
+.web-to-plex-prompt-option.mutable > h2 {
+ background: #0000 !important;
+ color: inherit !important;
+ font-family: inherit !important;
+ font-size: initial !important;
+ text-align: inherit !important;
+
+ margin: inherit !important;
+}
+
+.web-to-plex-prompt-option.mutable > .remove {
+ background: #ffffff40 !important;
+ border-radius: 3px !important;
+ transition: all 0.1s !important;
+
+ height: 30px !important;
+ width: 30px !important;
+
+ float: right !important;
+ margin-right: -2% !important;
+ margin-top: -8% !important;
+ padding: 0 !important;
+}
+
+.web-to-plex-prompt-option.mutable > .remove:hover {
+ background: #ffffff4d !important;
+}
+
+.web-to-plex-prompt-option.mutable > .remove::after {
+ content: '\00d7' !important;
+}
+
+.web-to-plex-prompt-option.mutable > .quality {
+ width: 50% !important;
+}
+
+.web-to-plex-prompt-option.mutable > .location {
+ width: 90% !important;
+}
+
+.web-to-plex-prompt-option.mutable > .location:last-child:not(:first-child) {
+ margin-top: 5px !important;
+}
+
+.web-to-plex-prompt-footer {
+ text-align: right !important;
+ border-bottom-left-radius: 3px !important;
+ border-bottom-right-radius: 3px !important;
+ border-top-color: #222 !important;
+ border-top-width: 1px !important;
+
+ bottom: 0 !important;
+}
+
+.web-to-plex-prompt-input {
+ float: left !important;
+ position: relative !important;
+ margin-left: -16px !important;
+ margin-top: -11px !important;
+}
+
+.web-to-plex-prompt-accept, .web-to-plex-prompt-decline {
+ transition: all 0.1s !important;
+}
+
+.web-to-plex-prompt-accept {
+ background: #cc7b19 !important;
+ margin-left: 5px !important;
+}
+
+.web-to-plex-prompt-accept:hover {
+ background: #e59029 !important;
+}
+
+.web-to-plex-prompt-decline {
+ background: #ffffff40 !important;
+}
+
+.web-to-plex-prompt-decline:hover {
+ background: #ffffff4d !important;
+}
+
+/* Web to Plex buttons */
+.web-to-plex-button [module] {
+ position: relative !important;
+}
+
+.web-to-plex-button * {
+ border: none !important;
+ text-transform: none !important;
+}
+
+.web-to-plex-button {
+ background-color: #3F4245 !important;
+ border: none !important;
+ color: #FFF !important;
+ font-family: Open Sans Semibold, Helvetica Neue, Helvetica, Arial, sans-serif !important;
+ font-size: 1em !important;
+ font-weight: 100 !important;
+ text-align: center !important;
+
+ bottom: 5px !important;
+ left: 5px !important;
+ padding: 10px !important;
+ position: fixed !important;
+ right: unset !important;
+ z-index: 999999 !important;
+
+ min-height: 0 !important;
+ min-width: 0 !important;
+ height: 72px !important;
+ width: 180px !important;
+
+ transition: all 0.3s ease !important;
+}
+
+.web-to-plex-button.hide {
+ display: initial !important;
+}
+
+.web-to-plex-button.hide:not(:hover), .web-to-plex-button.sleeper {
+ opacity: 0.1;
+}
+
+*:not(#plexit-bookmarklet-frame) ~ .web-to-plex-button {
+ margin-left: 0px !important;
+}
+
+#plexit-bookmarklet-frame ~ .web-to-plex-button {
+ margin-left: 280px !important;
+}
+
+*:not(#plexit-bookmarklet-frame) + .web-to-plex-button #plexit, #plexit-bookmarklet-frame + .web-to-plex-button #wtp-plexit {
+ display: none !important;
+}
+
+.floating.web-to-plex-button {
+ border-radius: 50px !important;
+ box-shadow: 0 4px 10px 0 rgba(0, 0, 0, 0.3) !important;
+
+ height: 75px !important;
+ width: 75px !important;
+}
+.floating.web-to-plex-button::after {
+ content: ' ' !important;
+
+ background: #666 !important;
+ border: 1px solid #888 !important;
+ border-radius: 16px !important;
+
+ right: 0 !important;
+ top: 0 !important;
+ position: absolute !important;
+
+ height: 16px !important;
+ width: 16px !important;
+
+ transition: background 0.4s linear !important;
+}
+
+.floating.web-to-plex-button:not(.restarting):active,
+.floating.web-to-plex-button:not(.restarting):hover {
+ box-shadow: 1px 5px 20px 0 rgba(0, 0, 0, 0.6) !important;
+ cursor: pointer !important;
+}
+
+.floating.web-to-plex-button:focus {
+ outline: #0000 !important;
+}
+
+.web-to-plex-button.wtp--download::after, .web-to-plex-button.wtp--download::before {
+ background: #265AF4 !important;
+}
+
+.web-to-plex-button.wtp--queued::after, .web-to-plex-button.wtp--queued::before {
+ background: #568AF4 !important;
+}
+
+.web-to-plex-button.wtp--found::after, .web-to-plex-button.wtp--found::before {
+ background: #F9BD03 !important;
+}
+
+.web-to-plex-button.wtp--error::after, .web-to-plex-button.wtp--error::before {
+ background: #FF2A2A !important;
+}
+
+.web-to-plex-button::before {
+ content: ' ' !important;
+
+ background: #FFF6 !important;
+ border-radius: inherit !important;
+ display: none !important;
+
+ margin-top: -10px !important;
+ margin-left: -10px !important;
+
+ height: 75px !important;
+ width: 75px !important;
+
+ position: absolute !important;
+ z-index: 9999999 !important;
+}
+
+.web-to-plex-button.animate::before {
+ display: block !important;
+
+ -webkit-transform: scale(0);
+ -moz-transform: scale(0);
+ -o-transform: scale(0);
+ transform: scale(0);
+
+ -webkit-animation: web-to-plex-ripple 0.5s linear;
+ -moz-animation: web-to-plex-ripple 0.5s linear;
+ -o-animation: web-to-plex-ripple 0.5s linear;
+ animation: web-to-plex-ripple 0.5s linear;
+}
+
+@-webkit-keyframes web-to-plex-ripple {
+ 100% {
+ opacity: 0;
+ -webkit-transform: scale(2.5);
+ }
+}
+@-moz-keyframes web-to-plex-ripple {
+ 100% {
+ opacity: 0;
+ -moz-transform: scale(2.5);
+ }
+}
+@-o-keyframes web-to-plex-ripple {
+ 100% {
+ opacity: 0;
+ -o-transform: scale(2.5);
+ }
+}
+@keyframes web-to-plex-ripple {
+ 100% {
+ opacity: 0;
+ transform: scale(2.5);
+ }
+}
+
+.web-to-plex-button.open, #plexit-bookmarklet-frame + .web-to-plex-button {
+ opacity: 1;
+
+ width: 350px !important;
+}
+.web-to-plex-button .list-name {
+ float: left !important;
+}
+
+.web-to-plex-button ul {
+ margin: 0 !important;
+ padding-left: 0 !important;
+}
+
+.web-to-plex-button li {
+ display: inline-block !important;
+ list-style: none !important;
+
+ margin: 0 !important;
+ padding: 5px !important;
+ vertical-align: bottom;
+}
+
+.web-to-plex-button li > img {
+ display: inline !important;
+
+ margin-top: 0 !important;
+}
+
+*:not(#plexit-bookmarklet-frame) + .web-to-plex-button.closed .list-item {
+ float: left !important;
+ opacity: 0;
+ transition: opacity 0 !important;
+}
+
+.web-to-plex-button.open .list-item {
+ opacity: 1;
+ transition: opacity 2s !important;
+}
+
+.web-to-plex-button.open li:hover [tooltip]::before, .web-to-plex-button.open [tooltip]:hover::before {
+ content: attr(tooltip) !important;
+
+ background: #3F424599 !important;
+ border-radius: 3px !important;
+ color: #fff !important;
+ font-family: arial, calibri, sans-serif, sans, monospace !important;
+ font-size: 15px !important;
+
+ bottom: 85px !important;
+ left: 35px !important;
+ padding: 3px 6px !important;
+ position: absolute !important;
+}
+
+/* bbodine @CodePen - https://codepen.io/bbodine1/pen/novBm */
+[class*="web-to-plex"] .checkbox {
+ width: 80px;
+ height: 26px;
+ background: #000;
+ margin: 15px 0;
+ position: relative;
+ border-radius: 50px;
+ box-shadow: inset 0px 1px 1px rgba(0, 0, 0, 0.5), 0px 1px 0px rgba(255, 255, 255, 0.2);
+}
+
+[class*="web-to-plex"] span.checkbox {
+ display: inline-block;
+
+ margin: 0;
+ vertical-align: text-bottom;
+}
+
+[class*="web-to-plex"] .checkbox::after {
+ content: 'OFF';
+ color: #666;
+ position: absolute;
+ right: 10px;
+ z-index: 0;
+ font: 12px/26px Arial, sans-serif;
+ font-weight: bold;
+ text-shadow: 1px 1px 0px rgba(255, 255, 255, 0.15);
+}
+
+[class*="web-to-plex"] .checkbox::before {
+ content: 'ON';
+ color: #cc7b19;
+ position: absolute;
+ left: 10px;
+ z-index: 0;
+ font: 12px/26px Arial, sans-serif;
+ font-weight: bold;
+}
+
+[class*="web-to-plex"] .checkbox[prompt-yes]::before {
+ content: attr(prompt-yes);
+ text-transform: uppercase;
+}
+
+[class*="web-to-plex"] .checkbox[prompt-no]::after {
+ content: attr(prompt-no);
+ text-transform: uppercase;
+}
+
+[class*="web-to-plex"] .checkbox[prompt-size="large"i]::before, .checkbox[prompt-size="large"i]::after {
+ font-size: 30px !important;
+}
+
+[class*="web-to-plex"] .checkbox[prompt-size="medium"i]::before, .checkbox[prompt-size="medium"i]::after {
+ font-size: 21px !important;
+}
+
+[class*="web-to-plex"] .checkbox[prompt-size="normal"i]::before, .checkbox[prompt-size="normal"i]::after {
+ font-size: 12px !important;
+}
+
+[class*="web-to-plex"] .checkbox[prompt-size="small"i]::before, .checkbox[prompt-size="small"i]::after {
+ font-size: 6px !important;
+}
+
+[class*="web-to-plex"] .checkbox[prompt="y/n"i]::before {
+ content: 'YES';
+}
+
+[class*="web-to-plex"] .checkbox[prompt="y/n"i]::after {
+ content: 'NO';
+}
+
+[class*="web-to-plex"] .checkbox label {
+ display: block;
+ width: 34px;
+ height: 20px;
+ cursor: pointer;
+ position: absolute;
+ top: 3px;
+ left: 3px;
+ z-index: 1;
+ background: #666;
+ border-radius: 50px;
+ transition: all 0.4s ease;
+ box-shadow: 0px 2px 5px 0px rgba(0, 0, 0, 0.3);
+}
+
+[class*="web-to-plex"] .checkbox input[type=checkbox] {
+ visibility: hidden;
+}
+
+[class*="web-to-plex"] .checkbox input[type=checkbox]:checked + label {
+ left: 43px;
+ background: #cc7b19;
+}
+
+[class*="web-to-plex"] .checkbox[disabled] {
+ opacity: 0.25 !important;
+}
diff --git a/win/sites/common.js b/win/sites/common.js
new file mode 100644
index 0000000..bf8f6f4
--- /dev/null
+++ b/win/sites/common.js
@@ -0,0 +1,5 @@
+/* global Update(type:string, details:object) */
+if(init && typeof init == 'function')
+ /* Do nothing */;
+else
+ (init = () => Update('PLUGIN', { instance_type: 'PLUGIN', plugin: location.hostname.replace(/(?:[\w\-]+\.)?([^\.]+)(?:\.[^\\\/]+)/, '$1') }))();
diff --git a/win/sites/couchpotato/index.css b/win/sites/couchpotato/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/couchpotato/index.js b/win/sites/couchpotato/index.js
new file mode 100644
index 0000000..7ef46cf
--- /dev/null
+++ b/win/sites/couchpotato/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'couchpotato' }))();
diff --git a/win/sites/fandango/index.css b/win/sites/fandango/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/fandango/index.js b/win/sites/fandango/index.js
new file mode 100644
index 0000000..366bb50
--- /dev/null
+++ b/win/sites/fandango/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'fandango' }))();
diff --git a/win/sites/flickmetrix/index.css b/win/sites/flickmetrix/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/flickmetrix/index.js b/win/sites/flickmetrix/index.js
new file mode 100644
index 0000000..4cbc2f3
--- /dev/null
+++ b/win/sites/flickmetrix/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'flickmetrix' }))();
diff --git a/win/sites/google/index.css b/win/sites/google/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/google/index.js b/win/sites/google/index.js
new file mode 100644
index 0000000..1e3c09f
--- /dev/null
+++ b/win/sites/google/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'google' }))();
diff --git a/win/sites/google/play.js b/win/sites/google/play.js
new file mode 100644
index 0000000..0d53cab
--- /dev/null
+++ b/win/sites/google/play.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'google.play' }))();
diff --git a/win/sites/gostream/index.css b/win/sites/gostream/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/gostream/index.js b/win/sites/gostream/index.js
new file mode 100644
index 0000000..cc74b85
--- /dev/null
+++ b/win/sites/gostream/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'gostream' }))();
diff --git a/win/sites/hulu/index.css b/win/sites/hulu/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/hulu/index.js b/win/sites/hulu/index.js
new file mode 100644
index 0000000..9b777ed
--- /dev/null
+++ b/win/sites/hulu/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'hulu' }))();
diff --git a/win/sites/imdb/index.css b/win/sites/imdb/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/imdb/index.js b/win/sites/imdb/index.js
new file mode 100644
index 0000000..1c4ef9a
--- /dev/null
+++ b/win/sites/imdb/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'imdb' }))();
diff --git a/win/sites/itunes/index.css b/win/sites/itunes/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/itunes/index.js b/win/sites/itunes/index.js
new file mode 100644
index 0000000..4f9ec1b
--- /dev/null
+++ b/win/sites/itunes/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'itunes' }))();
diff --git a/win/sites/justwatch/index.css b/win/sites/justwatch/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/justwatch/index.js b/win/sites/justwatch/index.js
new file mode 100644
index 0000000..b5dff44
--- /dev/null
+++ b/win/sites/justwatch/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'justwatch' }))();
diff --git a/win/sites/letterboxd/index.css b/win/sites/letterboxd/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/letterboxd/index.js b/win/sites/letterboxd/index.js
new file mode 100644
index 0000000..80cd852
--- /dev/null
+++ b/win/sites/letterboxd/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'letterboxd' }))();
diff --git a/win/sites/metacritic/index.css b/win/sites/metacritic/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/metacritic/index.js b/win/sites/metacritic/index.js
new file mode 100644
index 0000000..be3e7c5
--- /dev/null
+++ b/win/sites/metacritic/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'metacritic' }))();
diff --git a/win/sites/moviemeter/index.css b/win/sites/moviemeter/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/moviemeter/index.js b/win/sites/moviemeter/index.js
new file mode 100644
index 0000000..4bf6acf
--- /dev/null
+++ b/win/sites/moviemeter/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'moviemeter' }))();
diff --git a/win/sites/movieo/index.css b/win/sites/movieo/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/movieo/index.js b/win/sites/movieo/index.js
new file mode 100644
index 0000000..7738986
--- /dev/null
+++ b/win/sites/movieo/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'movieo' }))();
diff --git a/win/sites/netflix/index.css b/win/sites/netflix/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/netflix/index.js b/win/sites/netflix/index.js
new file mode 100644
index 0000000..5bc66c2
--- /dev/null
+++ b/win/sites/netflix/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'netflix' }))();
diff --git a/win/sites/plex/index.css b/win/sites/plex/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/plex/index.js b/win/sites/plex/index.js
new file mode 100644
index 0000000..f25c05c
--- /dev/null
+++ b/win/sites/plex/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'plex' }))();
diff --git a/win/sites/rottentomatoes/index.css b/win/sites/rottentomatoes/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/rottentomatoes/index.js b/win/sites/rottentomatoes/index.js
new file mode 100644
index 0000000..6fb798c
--- /dev/null
+++ b/win/sites/rottentomatoes/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'rottentomatoes' }))();
diff --git a/win/sites/theme.css b/win/sites/theme.css
new file mode 100644
index 0000000..57ada23
--- /dev/null
+++ b/win/sites/theme.css
@@ -0,0 +1,69 @@
+/* Themes and other stylings for Web to Plex */
+/* Button Layout (Reference)
+
+// - an optional value
+// `value` - an evaluated (calculated) value
+// A|B|... - A OR B OR ...
+
+// The main button (container)
+BUTTON [style].web-to-plex-button...
+ if the button is "floating" above elements
+ the buttons is hidden (by user interaction)
+ the button is shown (by user interaction)
+ the button is fully open (elliptical)
+ the button is closed (circular)
+
+ // (Container) Holds the LIs that perform the various actions
+ UL
+
+ // The ripple effect (colored indicator)
+ ::before
+
+ // The Web to Plex (logo) button
+ LI [tooltip]#wtp-list-name.list-name
+ // The actionable anchor
+ A [tooltip][href][`id`].list-action
+ IMG [src=/img/48.png] // Web to Plex logo
+
+ // The Plex It! button
+ LI [tooltip]#wtp-plexit.list-item
+ IMG [src=/img/plexit.48.png] // Alarm bell
+
+ // The Add to Plex It! button (normally hidden, until Plex It! is visible)
+ LI [tooltip][data]#plexit.list-item
+ DIV [tooltip][style][draggable=true]
+
+ // The hide button
+ LI [tooltip]#wtp-hide.list-item
+ IMG [src=/img/hide.48.png] // Eye icon
+
+ // The refresh button
+ LI [tooltip]#wtp-refresh.list-item
+ IMG [src=/img/reload.48.png] // Refresh
+
+ // The settings button
+ LI [tooltip]#wtp-options.list-item
+ IMG [src=/img/settings.48.png] // Gear icon
+
+ // The small, circular, colored indicator
+ ::after
+*/
+
+/* Button location */
+.web-to-plex-button.button-location-right {
+ left: unset !important;
+ right: 5px !important;
+}
+
+/* Button opacity - when hidden, and closed */
+.web-to-plex-button.hide.closed[button-opacity-hidden="0"] {
+ opacity: 0.00 !important;
+}
+
+.web-to-plex-button.hide.closed[button-opacity-hidden="5"] {
+ opacity: 0.05 !important;
+}
+
+.web-to-plex-button.hide.closed[button-opacity-hidden="10"] {
+ opacity: 0.10 !important;
+}
diff --git a/win/sites/tmdb/index.css b/win/sites/tmdb/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/tmdb/index.js b/win/sites/tmdb/index.js
new file mode 100644
index 0000000..6989aed
--- /dev/null
+++ b/win/sites/tmdb/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'tmdb' }))();
diff --git a/win/sites/trakt/index.css b/win/sites/trakt/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/trakt/index.js b/win/sites/trakt/index.js
new file mode 100644
index 0000000..4e035f8
--- /dev/null
+++ b/win/sites/trakt/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'trakt' }))();
diff --git a/win/sites/tubi/index.css b/win/sites/tubi/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/tubi/index.js b/win/sites/tubi/index.js
new file mode 100644
index 0000000..fbe9b35
--- /dev/null
+++ b/win/sites/tubi/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'tubi' }))();
diff --git a/win/sites/tvdb/index.css b/win/sites/tvdb/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/tvdb/index.js b/win/sites/tvdb/index.js
new file mode 100644
index 0000000..8486015
--- /dev/null
+++ b/win/sites/tvdb/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'tvdb' }))();
diff --git a/win/sites/tvmaze/index.css b/win/sites/tvmaze/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/tvmaze/index.js b/win/sites/tvmaze/index.js
new file mode 100644
index 0000000..d704d5c
--- /dev/null
+++ b/win/sites/tvmaze/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'tvmaze' }))();
diff --git a/win/sites/verizon/index.css b/win/sites/verizon/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/verizon/index.js b/win/sites/verizon/index.js
new file mode 100644
index 0000000..b7294b1
--- /dev/null
+++ b/win/sites/verizon/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'verizon' }))();
diff --git a/win/sites/vrv/index.css b/win/sites/vrv/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/vrv/index.js b/win/sites/vrv/index.js
new file mode 100644
index 0000000..c4a2052
--- /dev/null
+++ b/win/sites/vrv/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'vrv' }))();
diff --git a/win/sites/vudu/index.css b/win/sites/vudu/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/vudu/index.js b/win/sites/vudu/index.js
new file mode 100644
index 0000000..303f5ba
--- /dev/null
+++ b/win/sites/vudu/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'vudu' }))();
diff --git a/win/sites/vumoo/index.css b/win/sites/vumoo/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/vumoo/index.js b/win/sites/vumoo/index.js
new file mode 100644
index 0000000..2793ee0
--- /dev/null
+++ b/win/sites/vumoo/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'vumoo' }))();
diff --git a/win/sites/webtoplex/index.css b/win/sites/webtoplex/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/webtoplex/index.js b/win/sites/webtoplex/index.js
new file mode 100644
index 0000000..291f462
--- /dev/null
+++ b/win/sites/webtoplex/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'webtoplex' }))();
diff --git a/win/sites/youtube/index.css b/win/sites/youtube/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/win/sites/youtube/index.js b/win/sites/youtube/index.js
new file mode 100644
index 0000000..7512c79
--- /dev/null
+++ b/win/sites/youtube/index.js
@@ -0,0 +1,2 @@
+/* global Update(type:string, details:object) */
+(init = () => Update('SCRIPT', { script: 'youtube' }))();
diff --git a/win/utils.js b/win/utils.js
new file mode 100644
index 0000000..85c0a5a
--- /dev/null
+++ b/win/utils.js
@@ -0,0 +1,3174 @@
+/* eslint-disable no-unused-vars */
+/* global configuration, init, Update, "Helpers" */
+
+let configuration, init, Update;
+
+(async date => {
+
+ // default date items
+ let YEAR = date.getFullYear(),
+ MONTH = date.getMonth() + 1,
+ DATE = date.getDate(),
+ // Notification items
+ NOTIFIED = false,
+ RUNNING = false,
+ // Other items
+ /* Items that the user has already asked for */
+ CAUGHT, COMPRESS;
+
+ // simple helpers
+ let extURL = url => chrome.extension.getURL(url),
+ $ = (selector, container) => queryBy(selector, container),
+ // DO NOT EXPOSE
+ __CONFIG__, ALLOWED, PERMISS;
+
+ let IMG_URL = {
+ 'nil': extURL('img/null.png'),
+ 'icon_16': extURL('img/16.png'),
+ 'icon_48': extURL('img/48.png'),
+ 'background': extURL('img/background.png'),
+ 'hide_icon_16': extURL('img/hide.16.png'),
+ 'hide_icon_48': extURL('img/hide.48.png'),
+ 'show_icon_16': extURL('img/show.16.png'),
+ 'show_icon_48': extURL('img/show.48.png'),
+ 'close_icon_16': extURL('img/close.16.png'),
+ 'close_icon_48': extURL('img/close.48.png'),
+ 'icon_white_16': extURL('img/_16.png'),
+ 'icon_white_48': extURL('img/_48.png'),
+ 'plexit_icon_16': extURL('img/plexit.16.png'),
+ 'plexit_icon_48': extURL('img/plexit.48.png'),
+ 'reload_icon_16': extURL('img/reload.16.png'),
+ 'reload_icon_48': extURL('img/reload.48.png'),
+ 'icon_outline_16': extURL('img/o16.png'),
+ 'icon_outline_48': extURL('img/o48.png'),
+ 'noise_background': extURL('img/noise.png'),
+ 'settings_icon_16': extURL('img/settings.16.png'),
+ 'settings_icon_48': extURL('img/settings.48.png'),
+ };
+
+ // the storage - priority to sync
+ const UTILS_STORAGE = chrome.storage.sync || chrome.storage.local;
+
+ async function load(name = '') {
+ if(!name)
+ return /* invalid name */;
+
+ name = '~/cache/' + (name.toLowerCase().replace(/\s+/g, '_'));
+
+ return new Promise((resolve, reject) => {
+ function LOAD(DISK) {
+ let data = JSON.parse(DISK[name] || null);
+
+ return resolve(data);
+ }
+
+ UTILS_STORAGE.get(null, DISK => {
+ if(chrome.runtime.lastError)
+ chrome.storage.local.get(null, LOAD);
+ else
+ LOAD(DISK);
+ });
+ });
+ }
+
+ async function save(name = '', data) {
+ if(!name)
+ return /* invalid name */;
+
+ name = '~/cache/' + (name.toLowerCase().replace(/\s+/g, '_'));
+ data = JSON.stringify(data);
+
+ // erase entries after 400-500 have been made
+ UTILS_STORAGE.get(null, items => {
+ let array = [], bytes = 0;
+
+ for(let item in items) {
+ let object = items[item];
+
+ array.push(item);
+ bytes += (typeof object == 'string'? object.length * 8: typeof object == 'boolean'? 8: JSON.stringify(object).length * 8)|0;
+ }
+
+ if((UTILS_STORAGE.MAX_ITEMS && array.length >= UTILS_STORAGE.MAX_ITEMS) || bytes >= UTILS_STORAGE.QUOTA_BYTES) {
+ UTILS_TERMINAL.WARN('Exceeded quota. Erasing cache...');
+
+ for(let item in items)
+ if(/^~\/cache\/(?!get|has)/i.test(item))
+ UTILS_STORAGE.remove(item);
+
+ UTILS_TERMINAL.LOG('Cache erased');
+ }
+ });
+
+ await UTILS_STORAGE.set({[name]: data}, () => data);
+
+ return name;
+ }
+
+ async function remove(name) {
+ if(!name)
+ return /* invalid name */;
+
+ return await UTILS_STORAGE.remove(['~/cache/' + (name.toLowerCase().replace(/\s+/g, '_'))]);
+ }
+
+ function encode(data) {
+ if(/^[\u0000-\u00ff]+$/.test(data))
+ return btoa(data);
+ else
+ return data;
+ }
+
+ function decode(data) {
+ if(/^[a-z\d\+\/\=]+$/i.test(data))
+ return atob(data);
+ else
+ return data;
+ }
+
+ /* Notifications */
+ // create and/or queue a notification
+ // state = "warning" - red
+ // state = "error"
+ // state = "update" - blue
+ // state = "info" - grey
+ // anything else for state will show as orange
+ class Notification {
+ constructor(state, text, timeout = 7000, callback = () => {}, requiresClick = true) {
+ let queue = (Notification.queue = Notification.queue || { list: [] }),
+ last = queue.list[queue.list.length - 1] || document.queryBy('.web-to-plex-notification').first;
+
+ if(!__CONFIG__) {
+ Options();
+
+ throw 'No configuration saved...';
+ }
+
+ if(((state == 'error' || state == 'warning') && __CONFIG__.NotifyNewOnly && /\balready\s+(exists?|(been\s+)?added)\b/.test(text)) || (__CONFIG__.NotifyOnlyOnce && NOTIFIED && state === 'info'))
+ return /* Don't match /.../i as to not match item titles */;
+ NOTIFIED = true;
+
+ if(last && !last.done)
+ return (last => setTimeout(() => new Notification(state, text, timeout, callback, requiresClick), +(new Date) - last.start))(last);
+
+ let element = furnish(`div.web-to-plex-notification.${state}`, {
+ onmouseup: event => {
+ let notification = Notification.queue[event.target.id],
+ element = notification.element;
+
+ notification.done = true;
+ Notification.queue.list.splice(notification.index, 1);
+ clearTimeout(notification.job);
+ element.remove();
+
+ let removed = delete Notification.queue[notification.id];
+
+ return (event.requiresClick)? null: notification.callback(removed);
+ }
+ }, text);
+
+ queue[element.id = +(new Date)] = {
+ start: +element.id,
+ stop: +element.id + timeout,
+ span: +timeout,
+ done: false,
+ index: queue.list.length,
+ job: setTimeout(() => element.onmouseup({ target: element, requiresClick }), timeout),
+ id: +element.id,
+ callback, element
+ };
+ queue.list.push(queue[element.id]);
+
+ document.body.appendChild(element);
+
+ return queue[element.id];
+ }
+ }
+
+ class Prompt {
+ constructor(prompt_type, options, callback = () => {}, container = document.body) {
+ let prompt, remove,
+ array = (options instanceof Array? options: [].slice.call(options)),
+ data = [...array],
+ manager = {
+ movie: (
+ __CONFIG__.usingOmbi?
+ 'Ombi':
+ __CONFIG__.usingRadarr?
+ 'Radarr':
+ __CONFIG__.usingWatcher?
+ 'Watcher':
+ __CONFIG__.usingCouchPotato?
+ 'CouchPotato':
+ '???'
+ ),
+ show: (
+ __CONFIG__.usingOmbi?
+ 'Ombi':
+ __CONFIG__.usingSonarr?
+ 'Sonarr':
+ __CONFIG__.usingSickBeard?
+ 'SickBeard':
+ __CONFIG__.usingMedusa?
+ 'Medusa':
+ '???'
+ )
+ },
+ profiles = {
+ movie: JSON.parse(
+ __CONFIG__.usingRadarr?
+ __CONFIG__.radarrQualities:
+ __CONFIG__.usingWatcher?
+ __CONFIG__.watcherQualities:
+ '[]'
+ ),
+ show: JSON.parse(
+ __CONFIG__.usingSonarr?
+ __CONFIG__.sonarrQualities:
+ __CONFIG__.usingMedusa?
+ __CONFIG__.medusaQualities:
+ __CONFIG__.usingSickBeard?
+ __CONFIG__.sickBeardQualities:
+ '[]'
+ )
+ },
+ locations = {
+ movie: JSON.parse(
+ __CONFIG__.usingRadarr?
+ __CONFIG__.radarrStoragePaths:
+ __CONFIG__.usingWatcher?
+ __CONFIG__.watcherStoragePaths:
+ '[]'
+ ),
+ show: JSON.parse(
+ __CONFIG__.usingSonarr?
+ __CONFIG__.sonarrStoragePaths:
+ __CONFIG__.usingMedusa?
+ __CONFIG__.medusaStoragePaths:
+ __CONFIG__.usingSickBeard?
+ __CONFIG__.sickBeardStoragePaths:
+ '[]'
+ )
+ },
+ defaults = {
+ movie: (
+ __CONFIG__.usingRadarr?
+ { quality: __CONFIG__.__radarrQuality, location: __CONFIG__.__radarrStoragePath }:
+ {}
+ ),
+ show: (
+ __CONFIG__.usingSonarr?
+ { quality: __CONFIG__.__sonarrQuality, location: __CONFIG__.__sonarrStoragePath }:
+ __CONFIG__.usingMedusa?
+ { quality: __CONFIG__.__medusaQuality, location: __CONFIG__.__medusaStoragePath }:
+ __CONFIG__.usingSickBeard?
+ { quality: __CONFIG__.__sickBeardQuality, location: __CONFIG__.__sickBeardStoragePath }:
+ {}
+ )
+ },
+ slugs = {
+ movie: (
+ __CONFIG__.usingOmbi?
+ `${ __CONFIG__.ombiURL }`:
+ __CONFIG__.usingRadarr?
+ `${__CONFIG__.radarrURL}movies/%title%-%TMDbID%`:
+ __CONFIG__.usingWatcher?
+ `${__CONFIG__.watcherURL}library/status#%title%-%TMDbID%`:
+ __CONFIG__.usingCouchPotato?
+ `${ __CONFIG__.couchpotatoURL }`:
+ '#'
+ ),
+ show: (
+ __CONFIG__.usingOmbi?
+ `${ __CONFIG__.ombiURL }`:
+ __CONFIG__.usingSonarr?
+ `${__CONFIG__.sonarrURL}series/%title%`:
+ __CONFIG__.usingSickBeard?
+ `${__CONFIG__.sickBeardURL}home/displayShow?show=%TVDbID%`:
+ __CONFIG__.usingMedusa?
+ `${__CONFIG__.medusaURL}home/displayShow?indexername=tvdb&seriesid=%TVDbID%`:
+ '#'
+ )
+ },
+ slugify = type => slugs[type].replace(
+ /%([\w\-]+)%/gi, ($0, $1, $$, $_) => (typeof options[$1] != 'string')?
+ options[$1]:
+ options[$1]
+ .replace(/\&/g, 'and')
+ .replace(/\s+/g, '-')
+ .replace(/[^\w\-]+/g, '')
+ .replace(/\-{2,}/g, '-')
+ .toLowerCase()
+ );
+
+ let preX = document.queryBy('.web-to-plex-prompt').first;
+
+ if(preX)
+ return /* Ignore while another prompt is open, prevents double prompts */;
+
+ options.imdb = options.IMDbID;
+ options.tmdb = options.TMDbID;
+ options.tvdb = options.TVDbID;
+
+ switch(prompt_type) {
+ /* Allows the user to add and remove items from a list */
+ case 'prompt':
+ case 'input':
+ remove = element => {
+ let prompter = $('.web-to-plex-prompt').first,
+ header = $('.web-to-plex-prompt-header').first,
+ counter = $('.web-to-plex-prompt-options').first;
+
+ if(element === true)
+ return prompter.remove();
+ else
+ element.remove();
+
+ data.splice(+element.value, 1, null);
+ header.innerText = 'Approve ' + counter.children.length + (counter.children.length == 1?' item': ' items');
+ };
+
+ prompt = furnish('div.web-to-plex-prompt', {},
+ furnish('div.web-to-plex-prompt-body', { style: `background-image: url(${ IMG_URL.noise_background }), url(${ IMG_URL.background }); background-size: auto, cover;` },
+ // The prompt's title
+ furnish('h1.web-to-plex-prompt-header', {}, 'Approve ' + array.length + (array.length == 1? ' item': ' items')),
+
+ // The prompt's items
+ furnish('div.web-to-plex-prompt-options', {},
+ ...(ITEMS => {
+ let elements = [];
+
+ for(let index = 0, length = ITEMS.length, ITEM, P_QUA, P_LOC; index < length; index++) {
+ ITEM = ITEMS[index];
+
+ elements.push(
+ furnish('li.web-to-plex-prompt-option.mutable', { value: index, innerHTML: `${ index + 1 } \u00b7 ${ ITEM.title }${ ITEM.year? ` (${ ITEM.year })`: '' } \u2014 ${ ITEM.type } ` },
+ furnish('button.remove', { title: `Remove "${ ITEM.title }"`, onmouseup: event => { remove(event.target.parentElement); event.target.remove() } }),
+ (
+ __CONFIG__.PromptQuality?
+ P_QUA = furnish('select.quality', { index, onchange: event => data[event.target.getAttribute('index')].quality = event.target.value }, ...profiles[/(movie|film|cinema)/i.test(ITEM.type)?'movie':'show'].map(Q => furnish('option', { value: Q.id }, Q.name))):
+ ''
+ ),(
+ __CONFIG__.PromptLocation?
+ P_LOC = furnish('select.location', { index, onchange: event => data[event.target.getAttribute('index')].location = event.target.value }, ...locations[/(movie|film|cinema)/i.test(ITEM.type)?'movie':'show'].map(Q => furnish('option', { value: Q.id }, Q.path))):
+ ''
+ )
+ )
+ );
+
+ if(P_QUA) P_QUA.value = defaults[ITEM.type].quality;
+ if(P_LOC) P_LOC.value = defaults[ITEM.type].location;
+
+ P_QUA = P_LOC = null;
+ }
+
+ return elements
+ })(array)
+ ),
+
+ // The engagers
+ furnish('div.web-to-plex-prompt-footer', {},
+ furnish('input.web-to-plex-prompt-input[type=text]', { placeholder: 'Add an item (enter to add): Title (Year) Type / ID Type', title: 'Solo: A Star Wars Story (2018) movie / tt3778644 m', onkeydown: async event => {
+ if(event.keyCode == 13) {
+ let title, year, type, self = event.target, R = RegExp,
+ movie = /^(m(?:ovies?)?|f(?:ilms?)?|c(?:inemas?)?)/i,
+ Db, IMDbID, TMDbID, TVDbID, value = self.value;
+
+ self.setAttribute('disabled', self.disabled = true);
+ self.value = `Searching for "${ value }"...`;
+ data = data.filter(value => value !== null && value !== undefined);
+
+ if(/^\s*((?:tt)?\d+)(?:\s+(\w+)|\s*)?$/i.test(value)) {
+ let APIID = R.$1,
+ type = R.$2 || (data.length? data[0].type: 'movie'),
+ APIType = movie.test(type)? /^tt/i.test(APIID)? 'imdb': 'tmdb': 'tvdb';
+
+ type = movie.test(type)? 'movie': 'show';
+
+ Db = await Identify({ type, APIID, APIType });
+ IMDbID = Db.imdb;
+ TMDbID = Db.tmdb;
+ TVDbID = Db.tvdb;
+
+ title = Db.title;
+ year = Db.year;
+ } else if(/^([^]+)(\s*\(\d{2,4}\)\s*|\s+\d{2,4}\s+)([\w\s\-]+)$/.test(value)) {
+ title = R.$1;
+ year = R.$2 || YEAR + '';
+ type = R.$3 || (data.length? data[0].type: 'movie');
+
+ year = +year.replace(/\D/g, '').replace(/^\d{2}$/, '20$&');
+ type = movie.test(type)? 'movie': 'show';
+
+ Db = await Identify({ type, title, year });
+ IMDbID = Db.imdb;
+ TMDbID = Db.tmdb;
+ TVDbID = Db.tvdb;
+ }
+
+ event.preventDefault();
+ if(type && title && !(/^(?:tt)?$/i.test(IMDbID || '') && /^0?$/.test(+TMDbID | 0) && /^0?$/.test(+TVDbID | 0))) {
+ remove(true);
+ new Prompt(prompt_type, [{ ...Db, type, IMDbID, TMDbID, TVDbID }, ...data], callback, container);
+ } else {
+ self.disabled = self.removeAttribute('disabled');
+ self.value = value;
+ new Notification('error', `Couldn't find "${ value }"`);
+ }
+ }
+ } }),
+ furnish('button.web-to-plex-prompt-decline', { onmouseup: event => { remove(true); callback([]) }, title: 'Close' }, '\u2718'),
+ furnish('button.web-to-plex-prompt-accept', { onmouseup: event => { remove(true); new Prompt(prompt_type, options, callback, container) }, title: 'Reset' }, '\u21BA'),
+ furnish('button.web-to-plex-prompt-accept', { onmouseup: event => { remove(true); callback(data.filter(value => value !== null && value !== undefined)) }, title: 'Continue' }, '\u2714')
+ )
+ )
+ );
+ break;
+
+ /* Allows the user to remove predetermined items */
+ case 'select':
+ remove = element => {
+ let prompter = $('.web-to-plex-prompt').first,
+ header = $('.web-to-plex-prompt-header').first,
+ counter = $('.web-to-plex-prompt-options').first;
+
+ if(element === true)
+ return prompter.remove();
+ else
+ element.remove();
+
+ data.splice(+element.value, 1, null);
+ header.innerText = 'Approve ' + counter.children.length + (counter.children.length == 1?' item': ' items');
+ };
+
+ prompt = furnish('div.web-to-plex-prompt', {},
+ furnish('div.web-to-plex-prompt-body', { style: `background-image: url(${ IMG_URL.noise_background }), url(${ IMG_URL.background }); background-size: auto, cover;` },
+ // The prompt's title
+ furnish('h1.web-to-plex-prompt-header', {}, 'Approve ' + array.length + (array.length == 1? ' item': ' items')),
+
+ // The prompt's items
+ furnish('div.web-to-plex-prompt-options', {},
+ ...(ITEMS => {
+ let elements = [];
+
+ for(let index = 0, length = ITEMS.length, ITEM, P_QUA, P_LOC; index < length; index++) {
+ ITEM = ITEMS[index];
+
+ elements.push(
+ furnish('li.web-to-plex-prompt-option.mutable', { value: index, innerHTML: `${ index + 1 } \u00b7 ${ ITEM.title }${ ITEM.year? ` (${ ITEM.year })`: '' } \u2014 ${ ITEM.type } ` },
+ furnish('button.remove', { title: `Remove "${ ITEM.title }"`, onmouseup: event => { remove(event.target.parentElement); event.target.remove() } }),
+ (
+ __CONFIG__.PromptQuality?
+ P_QUA = furnish('select.quality', { index, onchange: event => data[event.target.getAttribute('index')].quality = event.target.value }, ...profiles[/(movie|film|cinema)/i.test(ITEM.type)?'movie':'show'].map(Q => furnish('option', { value: Q.id }, Q.name))):
+ ''
+ ),(
+ __CONFIG__.PromptLocation?
+ P_LOC = furnish('select.location', { index, onchange: event => data[event.target.getAttribute('index')].location = event.target.value }, ...locations[/(movie|film|cinema)/i.test(ITEM.type)?'movie':'show'].map(Q => furnish('option', { value: Q.id }, Q.path))):
+ ''
+ )
+ )
+ );
+
+ if(P_QUA) P_QUA.value = defaults[ITEM.type].quality;
+ if(P_LOC) P_LOC.value = defaults[ITEM.type].location;
+
+ P_QUA = P_LOC = null;
+ }
+
+ return elements
+ })(array)
+ ),
+
+ // The engagers
+ furnish('div.web-to-plex-prompt-footer', {},
+ furnish('button.web-to-plex-prompt-decline', { onmouseup: event => { remove(true); callback([]) }, title: 'Close' }, '\u2718'),
+ furnish('button.web-to-plex-prompt-accept', { onmouseup: event => { remove(true); new Prompt(prompt_type, options, callback, container) }, title: 'Reset' }, '\u21BA'),
+ furnish('button.web-to-plex-prompt-accept', { onmouseup: event => { remove(true); callback(data.filter(value => value !== null && value !== undefined)) }, title: 'Continue' }, '\u2714')
+ )
+ )
+ );
+ break;
+
+ /* Allows the user to modify a single item (before being pushed) */
+ case 'modify':
+ let { title, year, type, IMDbID, TMDbID, TVDbID } = options,
+ P_QUA, P_LOC;
+
+ let i = IMDbID,
+ t = TMDbID,
+ v = TVDbID,
+ s = 'style="text-decoration: none !important; color: #cc7b19 !important; font-style: italic !important;" target="_blank"';
+
+ i = /^tt-?$/.test(i)? '': i;
+ t = /^0?$/.test(t)? '': t;
+ v = /^0?$/.test(v)? '': v;
+
+ remove = element => {
+ let prompter = $('.web-to-plex-prompt').first,
+ header = $('.web-to-plex-prompt-header').first,
+ counter = $('.web-to-plex-prompt-options').first;
+
+ if(element === true)
+ return prompter.remove();
+ else
+ element.remove();
+ };
+
+ type = /(movie|film|cinema)/i.test(type)?'movie':'show';
+
+ prompt = furnish('div.web-to-plex-prompt', {},
+ furnish('div.web-to-plex-prompt-body', { style: `background-image: url(${ IMG_URL.noise_background }), url(${ IMG_URL.background }); background-size: auto, cover;` },
+ // The prompt's title
+ furnish('h1.web-to-plex-prompt-header', { innerHTML: `${ title }${ year? ` (${ year })`: '' }` }),
+
+ // The prompt's items
+ furnish('div.web-to-plex-prompt-options', {},
+ furnish('div.web-to-plex-prompt-option', { innerHTML: `${ type } \u2014 ${ i? `${i} `: '/' } \u2014 ${ t? `${t} `: '/' } \u2014 ${ v? `${v} `: '/' }` }),
+ (
+ __CONFIG__.PromptQuality?
+ P_QUA = furnish('select.quality', { onchange: event => options.quality = event.target.value }, ...profiles[type].map(Q => furnish('option', { value: Q.id }, Q.name))):
+ ''
+ ),
+ furnish('br'),
+ (
+ __CONFIG__.PromptLocation?
+ P_LOC = furnish('select.location', { onchange: event => options.location = event.target.value }, ...locations[type].map(Q => furnish('option', { value: Q.id }, Q.path))):
+ ''
+ )
+ ),
+
+ // The engagers
+ furnish('div.web-to-plex-prompt-footer', {},
+ furnish('button.web-to-plex-prompt-decline', { onmouseup: event => { remove(true); callback([]) }, title: 'Close' }, '\u2718'),
+ furnish('button.web-to-plex-prompt-accept', { onmouseup: event => { remove(true); new Prompt(prompt_type, options, callback, container) }, title: 'Reset' }, '\u21BA'),
+ furnish('button.web-to-plex-prompt-accept', { onmouseup: event => { remove(true); callback(options) }, title: 'Continue' }, '\u2714'),
+ furnish('button.web-to-plex-prompt-accept', { onmouseup: event => { let self = event.target; open(self.getAttribute('href'), '_blank') }, href: slugify(type), title: `Open on ${ manager[type] }` }, manager[type])
+ )
+ )
+ );
+
+ if(P_QUA) P_QUA.value = defaults[type].quality;
+ if(P_LOC) P_LOC.value = defaults[type].location;
+
+ P_QUA = P_LOC = null;
+ break;
+
+ case 'permission':
+ let { permission, name, alias } = options;
+ let existing, permissions;
+
+ /* Only one permission prompt allowed */
+ if(!(existing = $('.web-to-plex-prompt[type=permission]')).empty)
+ return existing.first;
+
+ UTILS_TERMINAL.LOG(`Asking for permission(s):`, options);
+
+ remove = element => {
+ let prompter = $('.web-to-plex-prompt').first,
+ header = $('.web-to-plex-prompt-header').first,
+ counter = $('.web-to-plex-prompt-options').first;
+
+ if(element === true)
+ return prompter.remove();
+ else
+ element.remove();
+ };
+
+ callback = (allowed, permissions) => {
+ save(`has/${ name }`, allowed);
+ save(`get/${ name }`, permissions);
+
+ ALLOWED = allowed;
+ PERMISS = permissions;
+
+ ParsedOptions();
+
+ return Update(`GRANT_PERMISSION`, { allowed, permissions }, true),
+ (init && !RUNNING? (init(), RUNNING = true): RUNNING = false);
+ };
+
+ prompt = furnish('div.web-to-plex-prompt', {},
+ furnish('div.web-to-plex-prompt-body', { style: `background-image: url(${ IMG_URL.noise_background }), url(${ IMG_URL.background }); background-size: auto, cover;` },
+ // The prompt's title
+ furnish('h1.web-to-plex-prompt-header', { innerHTML: `${ alias || name } (${ location.host }) would like:` }),
+
+ // The prompt's items
+ furnish('div.web-to-plex-prompt-options', {},
+ ...((permissions = permission.split(/\s*,\s*/).filter(v=>v&&v.length)).map(
+ __permission =>
+ furnish('div.web-to-plex-prompt-option', { innerHTML: `Access to your ${ __permission } information` })
+ )
+ )
+ ),
+
+
+ // The engagers
+ furnish('div.web-to-plex-prompt-footer', {},
+ furnish('button.web-to-plex-prompt-decline', { onmouseup: event => { remove(true); callback(false, {}) }, title: 'Deny' }, '\u2718'),
+ furnish('button.web-to-plex-prompt-accept', { onmouseup: event => { remove(true); callback(true, permissions) }, title: 'Allow' }, '\u2714')
+ )
+ )
+ );
+ break;
+
+ default:
+ return UTILS_TERMINAL.ERROR(`Unknown prompt type "${ prompt_type }"`);
+ break;
+ }
+
+ prompt.setAttribute('type', prompt_type);
+
+ return container.append(prompt), prompt;
+ }
+ }
+
+ // open up the options page
+ function Options() {
+ chrome.runtime.sendMessage({
+ type: 'OPEN_OPTIONS'
+ });
+ }
+
+ // "secret frame"
+ function sFrame(url, callbacks) {
+ let { success, error } = callbacks;
+
+ let frame = document.furnish('iframe#web-to-plex-sframe', {
+ src: url,
+ style: `
+ display: none !important;
+ opacity: 0 !important;
+ visibility: hidden !important;
+ `,
+
+ onload: success,
+ onerror: error,
+ });
+
+ // todo: make iframe, load, delete
+ document.body.append(frame);
+ }
+
+ function TLDHost(host) {
+ return host.replace(/^(ww\w+|\w{2})\./, '');
+ }
+
+ // Send an update query to background.js
+ Update = (type, options = {}, postToo) => {
+ if(configuration)
+ console.log(`Requesting update (${ type } [post-to-top=${ !!postToo }])`, options);
+ else if(!Update.retry)
+ try {
+ configuration = ParsedOptions();
+
+ Update.retry = true;
+
+ Update(type, options, postToo);
+
+ return;
+ } catch(error) {
+ console.warn(`Update failed... "${ error }" Attempting to save configuration...`);
+
+ return sFrame(extURL(`options/index.html#!/~save`), {
+ success: async event => {
+ let self = event.target;
+
+ await ParsedOptions();
+
+ Update(type, options, postToo);
+
+ self.remove();
+
+ return;
+ },
+
+ error: async event => {
+ let self = event.target;
+ self.remove();
+
+ new Notification('error', `Fill in missing Web to Plex options`, 15000, Options, false);
+
+ throw `Unable to set configuration variable: ${ JSON.stringify(configuration) }`;
+ }
+ });
+ }
+ else
+ return Update.retry = false;
+
+ let message = JSON.stringify({ type, options }),
+ index = -1;
+
+ Update.messages = Update.messages || [];
+
+ if(!~Update.messages.indexOf(message)) {
+ Update.messages.push(message);
+
+ chrome.runtime.sendMessage({
+ type,
+ options
+ }, response => {
+ if(response === undefined) {
+ console.warn(`Update response (${ type } [post-to-top=${ !!postToo }]): Invalid response...`, { response, options });
+ } else {
+ console.log(`Update response (${ type } [post-to-top=${ !!postToo }]):`, { response, options });
+ }
+ });
+ } else {
+ // the message was already sent, block it
+ }
+
+ // the message has only 1s to "live"
+ setTimeout(() => Update.messages.splice(index, 1), 1000);
+
+ if(postToo)
+ top.postMessage(options);
+ };
+
+ // get the saved options
+ function options() {
+ return new Promise((resolve, reject) => {
+ function handleOptions(options) {
+ if((!options.plexToken || !options.servers) && !options.IGNORE_PLEX)
+ return reject(new Error('Required options are missing')),
+ null;
+
+ let server, o;
+
+ if(!options.IGNORE_PLEX) {
+ // For now we support only one Plex server, but the options already
+ // allow multiple for easy migration in the future.
+ server = options.servers[0];
+ o = {
+ server: {
+ ...server,
+ // Compatibility for users who have not updated their settings yet.
+ connections: server.connections || [{ uri: server.url }]
+ },
+ ...options
+ };
+
+ options.plexURL = o.plexURL?
+ `${ o.plexURL }web#!/server/${ o.server.id }/`:
+ `https://app.plex.tv/web/app#!/server/${ o.server.id }/`;
+ } else {
+ o = options;
+ }
+
+ if(o.couchpotatoBasicAuthUsername)
+ o.couchpotatoBasicAuth = {
+ username: o.couchpotatoBasicAuthUsername,
+ password: o.couchpotatoBasicAuthPassword
+ };
+
+ // TODO: stupid copy/pasta
+ if(o.watcherBasicAuthUsername)
+ o.watcherBasicAuth = {
+ username: o.watcherBasicAuthUsername,
+ password: o.watcherBasicAuthPassword
+ };
+
+ if(o.radarrBasicAuthUsername)
+ o.radarrBasicAuth = {
+ username: o.radarrBasicAuthUsername,
+ password: o.radarrBasicAuthPassword
+ };
+
+ if(o.sonarrBasicAuthUsername)
+ o.sonarrBasicAuth = {
+ username: o.sonarrBasicAuthUsername,
+ password: o.sonarrBasicAuthPassword
+ };
+
+ if(o.medusaBasicAuthUsername)
+ o.medusaBasicAuth = {
+ username: o.medusaBasicAuthUsername,
+ password: o.medusaBasicAuthPassword
+ };
+
+ if(o.sickBeardBasicAuthUsername)
+ o.sickBeardBasicAuth = {
+ username: o.sickBeardBasicAuthUsername,
+ password: o.sickBeardBasicAuthPassword
+ };
+
+ if(o.usingOmbi && o.ombiURLRoot && o.ombiToken) {
+ o.ombiURL = o.ombiURLRoot;
+ } else {
+ delete o.ombiURL; // prevent variable ghosting
+ }
+
+ if(o.usingCouchPotato && o.couchpotatoURLRoot && o.couchpotatoToken) {
+ o.couchpotatoURL = `${ items.couchpotatoURLRoot }/api/${encodeURIComponent(o.couchpotatoToken)}`;
+ } else {
+ delete o.couchpotatoURL; // prevent variable ghosting
+ }
+
+ if(o.usingWatcher && o.watcherURLRoot && o.watcherToken) {
+ o.watcherURL = o.watcherURLRoot;
+ } else {
+ delete o.watcherURL; // prevent variable ghosting
+ }
+
+ if(o.usingRadarr && o.radarrURLRoot && o.radarrToken) {
+ o.radarrURL = o.radarrURLRoot;
+ } else {
+ delete o.radarrURL; // prevent variable ghosting
+ }
+
+ if(o.usingSonarr && o.sonarrURLRoot && o.sonarrToken) {
+ o.sonarrURL = o.sonarrURLRoot;
+ } else {
+ delete o.sonarrURL; // prevent variable ghosting
+ }
+
+ if(o.usingMedusa && o.medusaURLRoot && o.medusaToken) {
+ o.medusaURL = o.medusaURLRoot;
+ } else {
+ delete o.medusaURL; // prevent variable ghosting
+ }
+
+ if(o.usingSickBeard && o.sickBeardURLRoot && o.sickBeardToken) {
+ o.sickBeardURL = o.sickBeardURLRoot;
+ } else {
+ delete o.sickBeardURL; // prevent variable ghosting
+ }
+
+ resolve(o);
+ }
+
+ UTILS_STORAGE.get(null, options => {
+ if(chrome.runtime.lastError)
+ chrome.storage.local.get(null, handleOptions);
+ else
+ handleOptions(options);
+ });
+ });
+ }
+
+ // self explanatory, returns an object; sets the configuration variable
+ async function ParsedOptions() {
+ return await options()
+ .then(
+ options => {
+ configuration = {};
+
+ /* Don't expose the user's authentication information to sites */
+ for(let key in options)
+ if(/username|password|token|api|server|url|storage|cache|proxy|client|builtin|plugin|qualit/i.test(key))
+ if(ALLOWED && RegExp(PERMISS.join('|'),'i').test(key))
+ configuration[key] = options[key];
+ else
+ /* Do nothing */;
+ // else if(/(^cache-data|paths|qualities)/i.test(key))
+ // /* Pre-parse JSON - make sure anything accessing thedata handles objects too */
+ // configuration[key] = JSON.parse(options[key] || null);
+ else
+ /* Simple copy */
+ configuration[key] = options[key];
+
+ COMPRESS = options.UseLZW;
+ CAUGHT = options.__caught;
+ CAUGHT = JSON.parse(COMPRESS? iBWT(decompress(CAUGHT)): CAUGHT);
+ CAUGHT.bump = async(ids) => {
+ bumping:
+ for(let id in ids) {
+ let ID = id.toLowerCase().slice(0, 4),
+ MAX = (COMPRESS? 200: 100);
+
+ if(!!~CAUGHT[ID].indexOf(ids[id]))
+ continue bumping;
+
+ if(CAUGHT[ID].length >= MAX)
+ CAUGHT[ID].splice(0, 1 + (CAUGHT[ID].length - MAX));
+
+ CAUGHT[ID].push(ids[id]);
+ CAUGHT[ID].filter(v => typeof v == 'number'? v: null);
+ CAUGHT[ID] = CAUGHT[ID].slice(0, MAX);
+ }
+
+ let __caught = JSON.stringify(CAUGHT);
+
+ __caught = (COMPRESS? compress(BWT(__caught)): __caught);
+
+ await UTILS_STORAGE.set({ __caught }, () => configuration.__caught = __caught);
+ };
+
+ return __CONFIG__ = options;
+ },
+ error => {
+ new Notification(
+ 'warning',
+ 'Fill in missing Web to Plex options',
+ 15000,
+ Options
+ );
+ throw error;
+ }
+ );
+ }
+
+ await ParsedOptions();
+
+ let AUTO_GRAB = {
+ ENABLED: __CONFIG__.UseAutoGrab,
+ LIMIT: __CONFIG__.AutoGrabLimit,
+ },
+ UTILS_DEVELOPER = __CONFIG__.DeveloperMode, // = { true: Developer Mode, fase: Standard Mode }
+ UTILS_TERMINAL =
+ UTILS_DEVELOPER?
+ console:
+ { error: m => m, info: m => m, log: m => m, warn: m => m, group: m => m, groupEnd: m => m, LOG: m => m, ERROR: m => m, WARN: m => m };
+
+ UTILS_TERMINAL.LOG = UTILS_TERMINAL.LOG?
+ UTILS_TERMINAL.LOG:
+ (...messages) => {
+ if(messages.length == 1) {
+ let message = messages[0],
+ type = typeof message == 'object'? 'o': 'c';
+
+ (type == 'o')?
+ UTILS_TERMINAL.log(message):
+ UTILS_TERMINAL.log(
+ `%${ type }\u22b3 ${ message } `,
+ `
+ background-color: #00332b;
+ border-bottom: 1px solid #0000;
+ border-top: 1px solid #065;
+ box-sizing: border-box;
+ clear: right;
+ color: #f5f5f5;
+ display: block !important;
+ line-height: 2;
+ user-select: text;
+
+ flex-basis: 1;
+ flex-shrink: 1;
+
+ margin: 0;
+ overflow-wrap: break-word;
+ pading: 3px 22px 1px 0;
+ position: fixed;
+ z-index: -1;
+
+ min-height: 0;
+ min-width: 100%;
+ height: 100%;
+ width: 100%;
+ `
+ );
+ } else {
+ messages.forEach(message => UTILS_TERMINAL.LOG(message));
+ }
+ };
+
+ UTILS_TERMINAL.ERROR = UTILS_TERMINAL.ERROR?
+ UTILS_TERMINAL.ERROR:
+ (...messages) => {
+ if(messages.length == 1) {
+ let message = messages[0],
+ type = typeof message == 'object'? 'o': 'c';
+
+ (type == 'o')?
+ UTILS_TERMINAL.error(message):
+ UTILS_TERMINAL.log(
+ `%${ type }\u2298 ${ message } `,
+ `
+ background-color: #290000;
+ border-bottom: 1px solid #0000;
+ border-top: 1px solid #5c0000;
+ box-sizing: border-box;
+ clear: right;
+ color: #f5f5f5;
+ display: block !important;
+ line-height: 2;
+ user-select: text;
+
+ flex-basis: 1;
+ flex-shrink: 1;
+
+ margin: 0;
+ overflow-wrap: break-word;
+ pading: 3px 22px 1px 0;
+ position: fixed;
+ z-index: -1;
+
+ min-height: 0;
+ min-width: 100%;
+ height: 100%;
+ width: 100%;
+ `
+ );
+ } else {
+ messages.forEach(message => UTILS_TERMINAL.ERROR(message));
+ }
+ };
+
+ UTILS_TERMINAL.WARN = UTILS_TERMINAL.WARN?
+ UTILS_TERMINAL.WARN:
+ (...messages) => {
+ if(messages.length == 1) {
+ let message = messages[0],
+ type = typeof message == 'object'? 'o': 'c';
+
+ (type == 'o')?
+ UTILS_TERMINAL.warn(message):
+ UTILS_TERMINAL.log(
+ `%${ type }\u26a0 ${ message } `,
+ `
+ background-color: #332b00;
+ border-bottom: 1px solid #0000;
+ border-top: 1px solid #650;
+ box-sizing: border-box;
+ clear: right;
+ color: #f5f5f5;
+ display: block !important;
+ line-height: 2;
+ user-select: text;
+
+ flex-basis: 1;
+ flex-shrink: 1;
+
+ margin: 0;
+ overflow-wrap: break-word;
+ pading: 3px 22px 1px 0;
+ position: fixed;
+ z-index: -1;
+
+ min-height: 0;
+ min-width: 100%;
+ height: 100%;
+ width: 100%;
+ `
+ );
+ } else {
+ messages.forEach(message => UTILS_TERMINAL.WARN(message));
+ }
+ };
+
+ if(configuration) {
+ let host = TLDHost(location.host),
+ doms = configuration.__domains.split(',');
+
+ if(!~doms.indexOf(host))
+ return;
+ }
+
+ UTILS_TERMINAL.log('UTILS_DEVELOPER:', UTILS_DEVELOPER, configuration);
+
+ // parse the formatted headers and URL
+ function HandleProxyHeaders(Headers = "", URL = "") {
+ let headers = {};
+
+ Headers.replace(/^[ \t]*([^\=\s]+)[ \t]*=[ \t]*((["'`])(?:[^\\\3]*|\\.)\3|[^\f\n\r\v]*)/gm, ($0, $1, $2, $3, $$, $_) => {
+ let string = !!$3;
+
+ if(string) {
+ headers[$1] = $2.replace(RegExp(`^${ $3 }|${ $3 }$`, 'g'), '');
+ } else {
+ $2 = $2.replace(/@([\w\.]+)/g, (_0, _1, _$, __) => {
+ let path = _1.split('.'), property = top;
+
+ for(let index = 0, length = path.length; index < length; index++)
+ property = property[path[index]];
+
+ headers[$1] = property;
+ })
+ .replace(/@\{b(ase-?)?64-url\}/gi, btoa(URL))
+ .replace(/@\{enc(ode)?-url\}/gi, encodeURIComponent(URL))
+ .replace(/@\{(raw-)?url\}/gi, URL);
+ }
+ });
+
+ return headers;
+ }
+
+ // fetch/search for the item's media ID(s)
+ // rerun enum - [0bWXYZ] - [Tried Different URL | Tried Matching Title | Tried Loose Searching | Tried Rerunning Altogether]
+ async function Identify({ title, alttitle, year, type, IMDbID, TMDbID, TVDbID, APIType, APIID, meta, rerun }) {
+ let json = {}, // returned object
+ data = {}, // mutated object
+ promise, // query promise
+ api = {
+ tmdb: __CONFIG__.TMDbAPI || '37930f472ee15263f0b1ef5cc72e181a',
+ omdb: __CONFIG__.OMDbAPI || 'PlzBanMe',
+ ombi: __CONFIG__.ombiToken,
+ },
+ apit = APIType || type, // api type (depends on "rqut")
+ apid = APIID || null, // api id
+ iid = IMDbID || null, // IMDbID
+ mid = TMDbID || null, // TMDbID
+ tid = TVDbID || null, // TVDbID
+ rqut = apit, // request type: tmdb, imdb, or tvdb
+ manable = __CONFIG__.ManagerSearch && !(rerun & 0b1000), // is the user's "Manager Searches" option enabled?
+ UTF_16 = /[^0\u0020-\u007e, 1\u00a1\u00bf-\u00ff, 2\u0100-\u017f, 3\u0180-\u024f, 4\u0300-\u036f, 5\u0370-\u03ff, 6\u0400-\u04ff, 7\u0500-\u052f, 8\u20a0-\u20bf]+/g,
+ MV = /^(movies?|films?|cinemas?)$/i.test(apit),
+ TV = /^(tv[\s\-]*(?:shows?|series)?)$/i.test(apit);
+
+ type = type || null;
+ meta = { ...meta, mode: 'cors' };
+ rqut =
+ /(tv|show|series)/i.test(rqut)?
+ 'tvdb':
+ /(movie|film|cinema)s?/i.test(rqut)?
+ 'tmdb':
+ rqut || '*';
+ manable = manable && (__CONFIG__.usingOmbi || (__CONFIG__.usingRadarr && rqut == 'tmdb') || ((__CONFIG__.usingSonarr || __CONFIG__.usingMedusa /*|| __CONFIG__.usingSickBeard*/) && rqut == 'tvdb'));
+ title = (title? title.replace(/\s*[\:,]\s*seasons?\s+\d+.*$/i, '').toCaps(): "")
+ .replace(/[\u2010-\u2015]/g, '-') // fancy hyphen
+ .replace(/[\u201a\u275f]/g, ',') // fancy comma
+ .replace(/[\u2018\u2019\u201b\u275b\u275c`]/g, "'") // fancy apostrophe (tilde from anime results by TMDb)
+ .replace(/[\u201c-\u201f\u275d\u275e]/g, '"') // fancy quotation marks
+ .replace(UTF_16, ''); // only accept "usable" characters
+ /* 0[ -~], 1[¡¿-ÿ], 2[Ā-ſ], 3[ƀ-ɏ], 4[ò-oͯ], 5[Ͱ-Ͽ], 6[Ѐ-ӿ], 7[Ԁ-ԯ], 8[₠-₿] */
+ /** Symbol Classes
+ 0) Basic Latin, and standard characters
+ 1) Latin (Supplement)
+ 2) Latin Extended I
+ 3) Latin Extended II
+ 4) Diatrical Marks
+ 5) Greek & Coptic
+ 6) Basic Cyrillic
+ 7) Cyrillic (Supplement)
+ 8) Currency Symbols
+ */
+ year = year? (year + '').replace(/\D+/g, ''): year;
+
+ let plus = (string, character = '+') => string.replace(/\s+/g, character);
+
+ let local, savename;
+
+ if(year) {
+ savename = `${title} (${year}).${rqut}`.toLowerCase(),
+ local = await load(savename);
+ } else {
+ year = await load(`${title}.${rqut}`.toLowerCase()) || year;
+ savename = `${title} (${year}).${rqut}`.toLowerCase();
+ local = await load(savename);
+ }
+
+ if(local) {
+ UTILS_TERMINAL.LOG('[LOCAL] Search results', local);
+ return local;
+ }
+
+ /* the rest of this function is a beautiful mess that will need to be dealt with later... but it works */
+ let url =
+ (manable && title && __CONFIG__.usingOmbi && __CONFIG__.ombiURLRoot)?
+ `${ __CONFIG__.ombiURLRoot }api/v1/Search/${ (/^[it]mdb$/i.test(rqut) || MV)? 'movie': 'tv' }/${ plus(title, '%20') }/?apikey=${ api.ombi }`:
+ (manable && (__CONFIG__.usingRadarr || __CONFIG__.usingSonarr || __CONFIG__.usingMedusa /*|| __CONFIG__.usingSickBeard*/))?
+ (__CONFIG__.usingRadarr && /^[it]mdb$/i.test(rqut) && __CONFIG__.radarrURLRoot)?
+ (mid)?
+ `${ __CONFIG__.radarrURLRoot }api/movie/lookup/tmdb?tmdbId=${ mid }&apikey=${ __CONFIG__.radarrToken }`:
+ (iid)?
+ `${ __CONFIG__.radarrURLRoot }api/movie/lookup/imdb?imdbId=${ iid }&apikey=${ __CONFIG__.radarrToken }`:
+ `${ __CONFIG__.radarrURLRoot }api/movie/lookup?term=${ plus(title, '%20') }&apikey=${ __CONFIG__.radarrToken }`:
+ (__CONFIG__.usingSonarr && __CONFIG__.sonarrURLRoot)?
+ (tid)?
+ `${ __CONFIG__.sonarrURLRoot }api/series/lookup?term=tvdb:${ tid }&apikey=${ __CONFIG__.sonarrToken }`:
+ `${ __CONFIG__.sonarrURLRoot }api/series/lookup?term=${ plus(title, '%20') }&apikey=${ __CONFIG__.sonarrToken }`:
+ (__CONFIG__.usingMedusa && __CONFIG__.medusaURLRoot)?
+ (tid)?
+ `${ __CONFIG__.medusaURLRoot }api/v2/series/tvdb${ tid }?detailed=true&api_key=${ __CONFIG__.medusaToken }`:
+ `${ __CONFIG__.medusaURLRoot }api/v2/internal/searchIndexersForShowName?query=${ plus(title) }&indexerId=0&api_key=${ __CONFIG__.medusaToken }`:
+ /* TODO: find a way to get CORS to work on Sick Beard URLs (localhost) */
+ // (__CONFIG__.usingSickBeard)?
+ // (tid)?
+ // `${ __CONFIG__.sickBeardURLRoot }api/${ __CONFIG__.sickBeardToken }/?cmd=sb.searchtvdb&tvdbid=${ tid }`:
+ // `${ __CONFIG__.sickBeardURLRoot }api/${ __CONFIG__.sickBeardToken }/?cmd=sb.searchtvdb&name=${ encodeURIComponent(title) }`:
+ null:
+ (rqut == 'imdb' || (rqut == '*' && !iid && title) || (rqut == 'tvdb' && !iid && title && !(rerun & 0b1000)) && (rerun |= 0b1000))?
+ (iid)?
+ `https://www.omdbapi.com/?i=${ iid }&apikey=${ api.omdb }`:
+ (year)?
+ `https://www.omdbapi.com/?t=${ plus(title) }&y=${ year }&apikey=${ api.omdb }`:
+ `https://www.omdbapi.com/?t=${ plus(title) }&apikey=${ api.omdb }`:
+ (rqut == 'tmdb' || (rqut == '*' && !mid && title && year) || MV)?
+ (apit && apid)?
+ `https://api.themoviedb.org/3/${ MV? 'movie': 'tv' }/${ apid }?api_key=${ api.tmdb }`:
+ (iid || mid || tid)?
+ `https://api.themoviedb.org/3/find/${ iid || mid || tid }?api_key=${ api.tmdb }&external_source=${ iid? 'imdb': mid? 'tmdb': 'tvdb' }_id`:
+ `https://api.themoviedb.org/3/search/${ MV? 'movie': 'tv' }?api_key=${ api.tmdb }&query=${ encodeURI(title) }${ year? '&year=' + year: '' }`:
+ (rqut == 'tvdb' || (rqut == '*' && !tid && title) || (apid == tid))?
+ (tid)?
+ `https://api.tvmaze.com/shows/?thetvdb=${ tid }`:
+ (iid)?
+ `https://api.tvmaze.com/shows/?imdb=${ iid }`:
+ `https://api.tvmaze.com/search/shows?q=${ encodeURI(title) }`:
+ (title)?
+ (apit && year)?
+ `https://www.theimdbapi.org/api/find/${ MV? 'movie': 'show' }?title=${ encodeURI(title) }&year=${ year }`:
+ `https://www.theimdbapi.org/api/find/movie?title=${ encodeURI(title) }${ year? '&year=' + year: '' }`:
+ null;
+
+ if(url === null) return null;
+
+ let proxy = __CONFIG__.proxy,
+ cors = proxy.url, // if cors is requried and not uspported, proxy through this URL
+ headers = HandleProxyHeaders(proxy.headers, url);
+
+ if(proxy.enabled && /(^http:\/\/)(?!localhost|127\.0\.0\.1(?:\/8)?|::1(?:\/128)?|:\d+)\b/i.test(url)) {
+ url = cors
+ .replace(/\{b(ase-?)?64-url\}/gi, btoa(url))
+ .replace(/\{enc(ode)?-url\}/gi, encodeURIComponent(url))
+ .replace(/\{(raw-)?url\}/gi, url);
+
+ UTILS_TERMINAL.log({ proxy, url, headers });
+ }
+
+ UTILS_TERMINAL.LOG(`Searching for "${ title } (${ year })" in ${ type || apit }/${ rqut }${ proxy.enabled? '[PROXY]': '' } => ${ url }`, __CONFIG__);
+
+ await(proxy.enabled? fetch(url, { mode: "cors", headers }): fetch(url))
+ .then(response => response.text())
+ .then(data => {
+ try {
+ if(data)
+ json = JSON.parse(data);
+ } catch(error) {
+ UTILS_TERMINAL.error(`Failed to parse JSON: "${ data }"`);
+ }
+ })
+ .catch(error => {
+ throw error;
+ });
+
+ UTILS_TERMINAL.LOG('Search results', { title, year, url, json });
+
+ /* DO NOT change to else-if, won't work with Sick Beard: { data: { results: ... } } */
+ if('data' in json)
+ json = json.data;
+ if('results' in json)
+ json = json.results;
+
+ if(json instanceof Array) {
+ let b = { release_date: '', year: '' },
+ t = (s = "") => s.toLowerCase(),
+ c = (s = "") => t(s).replace(/\&/g, 'and').replace(UTF_16, ''),
+ k = (s = "") => {
+
+ let r = [
+ [/(?!^\s*)\b(show|series|a([st]|nd?|cross|fter|lthough)?|b(e(cause|fore|tween)|ut|y)|during|from|in(to)?|[io][fn]|[fn]?or|the|[st]o|through|under|with(out)?|yet)\b\s*/gi, ''],
+ // try replacing common words, e.g. Conjunctions, "Show," "Series," etc.
+ [/\^\s*|\s*$/g, ''],
+ [/\s+/g, '|'],
+ [/[\u2010-\u2015]/g, '-'], // fancy hyphen
+ [/[\u201a\u275f]/g, ','], // fancy comma
+ [/[\u2018\u2019\u201b\u275b\u275c`]/g, "'"], // fancy apostrophe (tilde from anime results by TMDb)
+ [/[\u201c-\u201f\u275d\u275e]/g, '"'], // fancy quotation marks
+ [/'(?=\B)|\B'/g, '']
+ ];
+
+ for(let i = 0; i < r.length; i++) {
+ if(/^([\(\|\)]+)?$/.test(s)) return "";
+
+ s = s.replace(r[i][0], r[i][1]);
+ }
+
+ return c(s);
+ },
+ R = (s = "", S = "", n = !0) => {
+ let l = s.split(' ').length, L = S.split(' ').length, E,
+ score = 100 * (((S.match(E = RegExp(`\\b(${k(s)})\\b`, 'gi')) || [null]).length) / (L || 1)),
+ passing = __CONFIG__.UseLooseScore | 0;
+
+ UTILS_TERMINAL.LOG(`\tQuick Match => "${ s }"/"${ S }" = ${ score }% (${ E })`);
+ score *= (l > L? (L||1)/l: L > l? (l||1)/L: 1);
+ UTILS_TERMINAL.LOG(`\tActual Match (${ passing }% to pass) ~> ... = ${ score }%`);
+
+ return (S != '' && score >= passing) || (n? R(S, s, !n): n);
+ },
+ en = /^(u[ks]-?|utf8-?)?en(glish)?$/i;
+
+ // Find an exact match: Title (Year) | #IMDbID
+ let index, found, $data, lastscore;
+ for(index = 0, found = false, $data, lastscore = 0; (title && year) && index < json.length && !found; rerun |= 0b0100, index++) {
+ $data = json[index];
+
+ let altt = ($data || {}).alternativeTitles,
+ $alt = (altt && altt.length? altt.filter(v => t(v) == t(title))[0]: null);
+
+ // Managers
+ if(manable) {
+ // Medusa
+ if(__CONFIG__.usingMedusa && $data instanceof Array)
+ found = ((t($data[4]) == t(title) || $alt) && +year == +$data[5].slice(0, 4))?
+ $alt || $data:
+ found;
+ // Radarr & Sonarr
+ else if(__CONFIG__.usingRadarr || __CONFIG__.usingSonarr)
+ found = ((t($data.title) == t(title) || $alt) && +year == +$data.year)?
+ $alt || $data:
+ found;
+ // Sick Beard
+ else if(__CONFIG__.usingSickBeard)
+ found = ((t($data.name) == t(title) || $alt) && +year == parseInt($data.first_aired))?
+ $alt || $data:
+ found;
+ }
+ //api.tvmaze.com/
+ else if($data && ('externals' in ($data = $data.show || $data) || 'show' in $data) && $data.premiered)
+ found = (iid == $data.externals.imdb || t($data.name) == t(title) && year == $data.premiered.slice(0, 4))?
+ $data:
+ found;
+ //api.themoviedb.org/ \local
+ else if($data && ('movie_results' in $data || 'tv_results' in $data || 'results' in $data) && $data.release_date)
+ found = (DATA => {
+ if(DATA.results)
+ if(rqut == 'tmdb')
+ DATA.movie_results = DATA.results;
+ else
+ DATA.tv_results = DATA.results;
+
+ let i, f, o, l;
+
+ for(i = 0, f = !1, o = DATA.movie_results, l = o.length | 0; i < l; i++)
+ f = (t(o.title) == t(title) && o.release_date.slice(0, 4) == year);
+
+ for(i = (+f * l), o = (f? o: DATA.tv_results), l = (f? l: o.length | 0); i < l; i++)
+ f = (t(o.name) == t(title) && o.first_air_date.slice(0, 4) == year);
+
+ return f? o: f = !!iid;
+ })($data);
+ //api.themoviedb.org/ \remote
+ else if($data && ('original_name' in $data || 'original_title' in $data) && $data.release_date)
+ found = (tid == $data.id || (t($data.original_name || $data.original_title) == t(title) || t($data.name) == t(title)) && year == ($data || b).release_date.slice(0, 4))?
+ $data:
+ found;
+ //theimdbapi.org/
+ else if($data && $data.release_date)
+ found = (t($data.title) == t(title) && year == ($data.url || $data || b).release_date.slice(0, 4))?
+ $data:
+ found;
+
+ UTILS_TERMINAL.LOG(`Strict Matching: ${ !!found }`, !!found? found: null);
+ }
+
+ // Find a close match: Title
+ for(index = 0; title && index < json.length && (!found || lastscore > 0); rerun |= 0b0100, index++) {
+ $data = json[index];
+
+ let altt = ($data || {}).alternativeTitles,
+ $alt = (altt && altt.length? altt.filter(v => c(v) == c(title)): null);
+
+ // Managers
+ if(manable) {
+ // Medusa
+ if(__CONFIG__.usingMedusa && $data instanceof Array)
+ found = (c($data[4]) == c(title) || $alt)?
+ $alt || $data:
+ found;
+ // Radarr & Sonarr
+ if(__CONFIG__.usingRadarr || __CONFIG__.usingSonarr)
+ found = (c($data.title) == c(title) || $alt)?
+ $alt || $data:
+ found;
+ // Sick Beard
+ if(__CONFIG__.usingSickBeard)
+ found = (c($data.name) == c(title) || $alt)?
+ $alt || $data:
+ found;
+ }
+ //api.tvmaze.com/
+ else if($data && ('externals' in ($data = $data.show || $data) || 'show' in $data))
+ found =
+ // ignore language barriers
+ (c($data.name) == c(title))?
+ $data:
+ // trust the api matching
+ ($data.score > lastscore)?
+ (lastscore = $data.score || $data.vote_count, $data):
+ found;
+ //api.themoviedb.org/ \local
+ else if('movie_results' in $data || 'tv_results' in $data || 'results' in $data)
+ found = (DATA => {
+ let i, f, o, l;
+
+ if(DATA.results)
+ if(rqut == 'tmdb')
+ DATA.movie_results = DATA.results;
+ else
+ DATA.tv_results = DATA.results;
+
+ for(i = 0, f = !1, o = DATA.movie_results, l = o.length | 0; i < l; i++)
+ f = (c(o.title) == c(title));
+
+ for(i = (+f * l), o = (f? o: DATA.tv_results), l = (f? l: o.length | 0); i < l; i++)
+ f = (c(o.name) == c(title));
+
+ return f? o: f;
+ })($data);
+ //api.themoviedb.org/ \remote
+ else if('original_name' in $data || 'original_title' in $data || 'name' in $data)
+ found = (c($data.original_name || $data.original_title || $data.name) == c(title))?
+ $data:
+ found;
+ //theimdbapi.org/
+ else if(en.test($data.language))
+ found = (c($data.title) == c(title))?
+ $data:
+ found;
+
+ UTILS_TERMINAL.LOG(`Title Matching: ${ !!found }`, !!found? found: null);
+ }
+
+ // Find an OK match (Loose Searching): Title ~ Title
+ // The examples below are correct
+ // GOOD, found: VRV's "Bakemonogatari" vs. TVDb's "Monogatari Series"
+ // /\b(monogatari)\b/i.test('bakemonogatari') === true
+ // this is what this option was designed for
+ // OK, found: "The Title of This is Bad" vs. "The Title of This is OK" (this is semi-errornous)
+ // /\b(title|this|bad)\b/i.test('title this ok') === true
+ // this may be a possible match, but it may also be an error: 'title' and 'this'
+ // the user's defined threshold is used in this case (above 65% would match these two items)
+ // BAD, not found: "Gun Show Showdown" vs. "Gundarr"
+ // /\b(gun|showdown)\b/i.test('gundarr') === false
+ // this should not match; the '\b' (border between \w and \W) keeps them from matching
+ for(index = 0; __CONFIG__.UseLoose && title && index < json.length && (!found || lastscore > 0); rerun |= 0b0010, index++) {
+ $data = json[index];
+
+ let altt = ($data || {}).alternativeTitles,
+ $alt = (altt && altt.length? altt.filter(v => R(v, title)): null);
+
+ // Managers
+ if(manable) {
+ // Medusa
+ if(__CONFIG__.usingMedusa && $data instanceof Array)
+ found = (R($data[4], title) || $alt)?
+ $alt || $data:
+ found;
+ // Radarr & Sonarr
+ if(__CONFIG__.usingRadarr || __CONFIG__.usingSonarr)
+ found = (R($data.name || $data.title, title) || $alt)?
+ $alt || $data:
+ found;
+ // Sick Beard
+ if(__CONFIG__.usingSickBeard)
+ found = (R($data.name, title) || $alt)?
+ $alt || $data:
+ found;
+ }
+ //api.tvmaze.com/
+ else if($data && ('externals' in ($data = $data.show || $data) || 'show' in $data))
+ found =
+ // ignore language barriers
+ (R($data.name, title) || UTILS_TERMINAL.LOG('Matching:', [$data.name, title], R($data.name, title)))?
+ $data:
+ // trust the api matching
+ ($data.score > lastscore)?
+ (lastscore = $data.score, $data):
+ found;
+ //api.themoviedb.org/ \local
+ else if($data && ('movie_results' in $data || 'tv_results' in $data))
+ found = (DATA => {
+ let i, f, o, l;
+
+ for(i = 0, f = !1, o = DATA.movie_results, l = o.length | 0; i < l; i++)
+ f = R(o.title, title);
+
+ for(i = (+f * l), o = (f? o: DATA.tv_results), l = (f? l: o.length | 0); i < l; i++)
+ f = R(o.name, title);
+
+ return f? o: f;
+ })($data);
+ //api.themoviedb.org/ \remote
+ else if($data && ('original_name' in $data || 'original_title' in $data))
+ found = (R($data.original_name, title) || R($data.original_title, title) || R($data.name, title))?
+ $data:
+ found;
+ //theimdbapi.org/
+ else if($data && en.test($data.language))
+ found = (R($data.title, title))?
+ $data:
+ found;
+
+ UTILS_TERMINAL.LOG(`Loose Matching: ${ !!found }`, !!found? found: null);
+ }
+
+ json = found;
+ }
+
+ if((json === undefined || json === null || json === false) && !(rerun & 0b0001))
+ return UTILS_TERMINAL.WARN(`Trying to find "${ title }" again (as "${ (alttitle || title) }")`), rerun |= 0b0001, json = Identify({ title: (alttitle || title), year: YEAR, type, IMDbID, TMDbID, TVDbID, APIType, APIID, meta, rerun });
+ else if((json === undefined || json === null))
+ json = { IMDbID, TMDbID, TVDbID };
+
+ let ei = 'tt',
+ mr = 'movie_results',
+ tr = 'tv_results';
+
+ json = json && mr in json? json[mr].length > json[tr].length? json[mr]: json[tr]: json;
+
+ if(json instanceof Array && (!__CONFIG__.usingMedusa? true: (__CONFIG__.usingSonarr || __CONFIG__.usingOmbi || __CONFIG__.usingSickBeard)))
+ json = json[0];
+
+ if(!json)
+ json = { IMDbID, TMDbID, TVDbID };
+
+ // Ombi, Medusa, Radarr and Sonarr
+ if(manable)
+ data = (
+ (__CONFIG__.usingMedusa && !(__CONFIG__.usingSonarr || __CONFIG__.usingOmbi || __CONFIG__.usingSickBeard))?
+ {
+ imdb: iid || ei,
+ tmdb: mid | 0,
+ tvdb: tid || json[3] || (json[8]? json[8][1]: 0),
+ title: json.title || title,
+ year: +(json.year || year)
+ }:
+ {
+ imdb: iid || json.imdbId || ei,
+ tmdb: mid || json.tmdbId || json.theMovieDbId | 0,
+ tvdb: tid || json.tvdbId || json.theTvDbId | 0,
+ title: json.title || title,
+ year: +(json.year || year)
+ }
+ );
+ //api.tvmaze.com/
+ else if('externals' in (json = json.show || json))
+ data = {
+ imdb: iid || json.externals.imdb || ei,
+ tmdb: mid || json.externals.themoviedb | 0,
+ tvdb: tid || json.externals.thetvdb | 0,
+ title: json.name || title,
+ year: ((json.premiered || json.first_aired_date || year) + '').slice(0, 4)
+ };
+ //api.themoviedb.org/
+ else if('imdb_id' in (json = mr in json? json[mr].length > json[tr].length? json[mr]: json[tr]: json) || 'original_name' in json || 'original_title' in json)
+ data = {
+ imdb: iid || json.imdb_id || ei,
+ tmdb: mid || json.id | 0,
+ tvdb: tid || json.tvdb | 0,
+ title: json.title || json.name || title,
+ year: ((json.release_date || json.first_air_date || year) + '').slice(0, 4)
+ };
+ //omdbapi.com/
+ else if('imdbID' in json)
+ data = {
+ imdb: iid || json.imdbID || ei,
+ tmdb: mid || json.tmdbID | 0,
+ tvdb: tid || json.tvdbID | 0,
+ title: json.Title || json.Name || title,
+ year: json.Year || year
+ };
+ //theapache64.com/movie_db/
+ else if('data' in json)
+ data = {
+ imdb: iid || json.data.imdb_id || ei,
+ tmdb: mid || json.data.tmdb_id | 0,
+ tvdb: tid || json.data.tvdb_id | 0,
+ title: json.data.name || json.data.title || title,
+ year: json.data.year || year
+ };
+ //theimdbapi.org/
+ else if('imdb' in json)
+ data = {
+ imdb: iid || json.imdb || ei,
+ tmdb: mid || json.id | 0,
+ tvdb: tid || json.tvdb | 0,
+ title,
+ year
+ };
+ // given by the requesting service
+ else
+ data = {
+ imdb: iid || ei,
+ tmdb: mid | 0,
+ tvdb: tid | 0,
+ title,
+ year
+ };
+
+ year = +((data.year + '').slice(0, 4)) || 0;
+ data.year = year;
+
+ let best = { title, year, data, type, rqut, score: json.score | 0 };
+
+ UTILS_TERMINAL.LOG(`Best match: ${ url }`, { best, json });
+
+ if(best.data.imdb == ei && best.data.tmdb == 0 && best.data.tvdb == 0)
+ return UTILS_TERMINAL.ERROR(`No information was found for "${ title } (${ year })"`), {};
+
+ save(savename, data); // e.g. "Coco (0)" on Netflix before correction / no repeat searches
+ save(savename = `${title} (${year}).${rqut}`.toLowerCase(), data); // e.g. "Coco (2017)" on Netflix after correction / no repeat searches
+ save(`${title}.${rqut}`.toLowerCase(), year);
+
+ UTILS_TERMINAL.LOG(`Saved as "${ savename }"`, data);
+
+ rerun |= 0b00001;
+
+ return data;
+ }
+
+ function Request_CouchPotato(options) {
+ // TODO: this does not work anymore!
+ if(!options.IMDbID)
+ return new Notification(
+ 'warning',
+ 'Stopped adding to CouchPotato: No IMDb ID'
+ );
+
+ chrome.runtime.sendMessage(
+ {
+ type: 'VIEW_COUCHPOTATO',
+ url: `${ __CONFIG__.couchpotatoURL }/media.get`,
+ IMDbID: options.IMDbID,
+ TMDbID: options.TMDbID,
+ TVDbID: options.TVDbID,
+ basicAuth: __CONFIG__.couchpotatoBasicAuth,
+ },
+ response => {
+ let movieExists = response.success;
+ if(response.error) {
+ new Notification(
+ 'warning',
+ 'CouchPotato request failed (see your console)'
+ );
+ return (!response.silent && UTILS_TERMINAL.error('Error viewing CouchPotato: ' + String(response.error)));
+ }
+ if(!movieExists) {
+ __Request_CouchPotato__(options);
+ return;
+ }
+ new Notification(
+ 'warning',
+ `Movie already exists in CouchPotato (status: ${response.status})`
+ );
+ }
+ );
+ }
+
+ // Movies/TV Shows
+ function Request_Ombi(options) {
+ new Notification('info', `Sending "${ options.title }" to Ombi`, 3000);
+
+ if((!options.IMDbID && !options.TMDbID) && !options.TVDbID) {
+ return new Notification(
+ 'warning',
+ 'Stopped adding to Ombi: No content ID'
+ );
+ }
+
+ let contentType = (/movies?|film/i.test(options.type)? 'movie': 'tv');
+
+ chrome.runtime.sendMessage({
+ type: 'PUSH_OMBI',
+ url: `${ __CONFIG__.ombiURL }api/v1/Request/${ contentType }`,
+ token: __CONFIG__.ombiToken,
+ title: options.title,
+ year: options.year,
+ imdbId: options.IMDbID,
+ tmdbId: options.TMDbID,
+ tvdbId: options.TVDbID,
+ contentType,
+ },
+ response => {
+ UTILS_TERMINAL.log('Pushing to Ombi', response);
+
+ if(response && response.error) {
+ new Notification('warning', `Could not add "${ options.title }" to Ombi: ${ response.error }`);
+ return (!response.silent && UTILS_TERMINAL.error('Error adding to Ombi: ' + String(response.error), response.location, response.debug));
+ } else if(response && response.success) {
+ let title = options.title.replace(/\&/g, 'and').replace(/\s+/g, '-').replace(/[^\w\-]+/g, '').replace(/\-{2,}/g, '-').toLowerCase(),
+ { IMDbID, TMDbID, TVDbID } = options;
+
+ CAUGHT.bump({ IMDbID, TMDbID, TVDbID });
+
+ UTILS_TERMINAL.LOG('Successfully pushed', options);
+ new Notification('update', `Added "${ options.title }" to Ombi`, 7000, () => window.open(__CONFIG__.ombiURL, '_blank'));
+ } else {
+ new Notification('warning', `Could not add "${ options.title }" to Ombi: Unknown Error`);
+ (!(response && response.silent) && UTILS_TERMINAL.error('Error adding to Ombi: ' + String(response)));
+ }
+ }
+ );
+ }
+
+ // Movies/TV Shows
+ function __Request_CouchPotato__(options) {
+ new Notification('info', `Sending "${ options.title }" to CouchPotato`, 3000);
+
+ chrome.runtime.sendMessage(
+ {
+ type: 'PUSH_COUCHPOTATO',
+ url: `${ __CONFIG__.couchpotatoURL }/movie.add`,
+ IMDbID: options.IMDbID,
+ TMDbID: options.TMDbID,
+ TVDbID: options.TVDbID,
+ basicAuth: __CONFIG__.couchpotatoBasicAuth,
+ },
+ response => {
+ UTILS_TERMINAL.log('Pushing to CouchPotato', response);
+
+ if(response.error) {
+ new Notification(
+ 'warning',
+ `Could not add "${ options.title }" to CouchPotato (see your console)`
+ );
+ return (!response.silent && UTILS_TERMINAL.error('Error adding to CouchPotato: ' + String(response.error), response.location, response.debug));
+ }
+ if(response.success) {
+ let { IMDbID, TMDbID, TVDbID } = options;
+
+ CAUGHT.bump({ IMDbID, TMDbID, TVDbID });
+
+ UTILS_TERMINAL.LOG('Successfully pushed', options);
+ new Notification('update', `Added "${ options.title }" to CouchPotato`);
+ } else {
+ new Notification('warning', `Could not add "${ options.title }" to CouchPotato`);
+ }
+ }
+ );
+ }
+
+ // Movies
+ function Request_Watcher(options) {
+ new Notification('info', `Sending "${ options.title }" to Watcher`, 3000);
+
+ if(!options.IMDbID && !options.TMDbID) {
+ return new Notification(
+ 'warning',
+ 'Stopped adding to Watcher: No IMDb/TMDb ID'
+ );
+ }
+
+ chrome.runtime.sendMessage({
+ type: 'PUSH_WATCHER',
+ url: `${ __CONFIG__.watcherURL }api/`,
+ token: __CONFIG__.watcherToken,
+ StoragePath: __CONFIG__.watcherStoragePath,
+ basicAuth: __CONFIG__.watcherBasicAuth,
+ title: options.title,
+ year: options.year,
+ imdbId: options.IMDbID,
+ tmdbId: options.TMDbID,
+ },
+ response => {
+ UTILS_TERMINAL.log('Pushing to Watcher', response);
+
+ if(response && response.error) {
+ new Notification('warning', `Could not add "${ options.title }" to Watcher: ${ response.error }`);
+ return (!response.silent && UTILS_TERMINAL.error('Error adding to Watcher: ' + String(response.error), response.location, response.debug));
+ } else if(response && (response.success || (response.response + "") == "true")) {
+ let title = options.title.replace(/\&/g, 'and').replace(/\s+/g, '-').replace(/[^\w\-]+/g, '').replace(/\-{2,}/g, '-').toLowerCase(),
+ TMDbID = options.TMDbID || response.tmdbId,
+ IMDbID = options.IMDbID || response.imdbId;
+
+ CAUGHT.bump({ IMDbID, TMDbID });
+
+ UTILS_TERMINAL.LOG('Successfully pushed', options);
+ new Notification('update', `Added "${ options.title }" to Watcher`, 7000, () => window.open(`${__CONFIG__.watcherURL}library/status${TMDbID? `#${title}-${TMDbID}`: '' }`, '_blank'));
+ } else {
+ new Notification('warning', `Could not add "${ options.title }" to Watcher: Unknown Error`);
+ (!(response && response.silent) && UTILS_TERMINAL.error('Error adding to Watcher: ' + String(response)));
+ }
+ }
+ );
+ }
+
+ // Movies
+ function Request_Radarr(options, prompted) {
+ if(!options.IMDbID && !options.TMDbID)
+ return (!prompted)? new Notification(
+ 'warning',
+ 'Stopped adding to Radarr: No IMDb/TMDb ID'
+ ): null;
+
+ let PromptValues = {},
+ { PromptQuality, PromptLocation } = __CONFIG__;
+
+ if(!prompted && (PromptQuality || PromptLocation))
+ return new Prompt('modify', options, refined => Request_Radarr(refined, true));
+
+ if(PromptQuality && +options.quality > 0)
+ PromptValues.QualityID = +options.quality;
+ if(PromptLocation && options.location)
+ PromptValues.StoragePath = JSON.parse(__CONFIG__.radarrStoragePaths).map(item => item.id == options.location? item: null).filter(n => n)[0].path.replace(/\\/g, '\\\\');
+
+ new Notification('info', `Sending "${ options.title }" to Radarr`, 3000);
+
+ chrome.runtime.sendMessage({
+ type: 'PUSH_RADARR',
+ url: `${ __CONFIG__.radarrURL }api/movie/`,
+ token: __CONFIG__.radarrToken,
+ StoragePath: __CONFIG__.radarrStoragePath,
+ QualityID: __CONFIG__.radarrQualityProfileId,
+ basicAuth: __CONFIG__.radarrBasicAuth,
+ title: options.title,
+ year: options.year,
+ imdbId: options.IMDbID,
+ tmdbId: options.TMDbID,
+ ...PromptValues
+ },
+ response => {
+ UTILS_TERMINAL.log('Pushing to Radarr', response);
+
+ if(response && response.error) {
+ new Notification('warning', `Could not add "${ options.title }" to Radarr: ${ response.error }`);
+ return (!response.silent && UTILS_TERMINAL.error('Error adding to Radarr: ' + String(response.error), response.location, response.debug));
+ } else if(response && response.success) {
+ let title = options.title.replace(/\&/g, 'and').replace(/\s+/g, '-').replace(/[^\w\-]+/g, '').replace(/\-{2,}/g, '-').toLowerCase(),
+ TMDbID = options.TMDbID || response.tmdbId,
+ IMDbID = options.IMDbID || response.imdbId;
+
+ CAUGHT.bump({ IMDbID, TMDbID });
+
+ UTILS_TERMINAL.LOG('Successfully pushed', options);
+ new Notification('update', `Added "${ options.title }" to Radarr`, 7000, () => window.open(`${__CONFIG__.radarrURL}${TMDbID? `movies/${title}-${TMDbID}`: '' }`, '_blank'));
+ } else {
+ new Notification('warning', `Could not add "${ options.title }" to Radarr: Unknown Error`);
+ (!(response && response.silent) && UTILS_TERMINAL.error('Error adding to Radarr: ' + String(response)));
+ }
+ }
+ );
+ }
+
+ // TV Shows
+ function Request_Sonarr(options, prompted) {
+ if(!options.TVDbID)
+ return (!prompted)? new Notification(
+ 'warning',
+ 'Stopped adding to Sonarr: No TVDb ID'
+ ): null;
+
+ let PromptValues = {},
+ { PromptQuality, PromptLocation } = __CONFIG__;
+
+ if(!prompted && (PromptQuality || PromptLocation))
+ return new Prompt('modify', options, refined => Request_Sonarr(refined, true));
+
+ if(PromptQuality && +options.quality > 0)
+ PromptValues.QualityID = +options.quality;
+ if(PromptLocation && options.location)
+ PromptValues.StoragePath = JSON.parse(__CONFIG__.sonarrStoragePaths).map(item => item.id == options.location? item: null).filter(n => n)[0].path.replace(/\\/g, '\\\\');
+
+ new Notification('info', `Sending "${ options.title }" to Sonarr`, 3000);
+
+ chrome.runtime.sendMessage({
+ type: 'PUSH_SONARR',
+ url: `${ __CONFIG__.sonarrURL }api/series/`,
+ token: __CONFIG__.sonarrToken,
+ StoragePath: __CONFIG__.sonarrStoragePath,
+ QualityID: __CONFIG__.sonarrQualityProfileId,
+ basicAuth: __CONFIG__.sonarrBasicAuth,
+ title: options.title,
+ year: options.year,
+ tvdbId: options.TVDbID,
+ ...PromptValues
+ },
+ response => {
+ UTILS_TERMINAL.log('Pushing to Sonarr', response);
+
+ if(response && response.error) {
+ new Notification('warning', `Could not add "${ options.title }" to Sonarr: ${ response.error }`);
+ return (!response.silent && UTILS_TERMINAL.error('Error adding to Sonarr: ' + String(response.error), response.location, response.debug));
+ } else if(response && response.success) {
+ let title = options.title.replace(/\&/g, 'and').replace(/\s+/g, '-').replace(/[^\w\-]+/g, '').replace(/\-{2,}/g, '-').toLowerCase(),
+ TVDbID = options.TVDbID || response.tvdbId;
+
+ CAUGHT.bump({ TVDbID });
+
+ UTILS_TERMINAL.LOG('Successfully pushed', options);
+ new Notification('update', `Added "${ options.title }" to Sonarr`, 7000, () => window.open(`${__CONFIG__.sonarrURL}series/${title}`, '_blank'));
+ } else {
+ new Notification('warning', `Could not add "${ options.title }" to Sonarr: Unknown Error`);
+ (!(response && response.silent) && UTILS_TERMINAL.error('Error adding to Sonarr: ' + String(response)));
+ }
+ }
+ );
+ }
+
+ // TV Shows
+ function Request_Medusa(options, prompted) {
+ if(!options.TVDbID)
+ return (!prompted)? new Notification(
+ 'warning',
+ 'Stopped adding to Medusa: No TVDb ID'
+ ): null;
+
+ let PromptValues = {},
+ { PromptQuality, PromptLocation } = __CONFIG__;
+
+ if(!prompted && (PromptQuality || PromptLocation))
+ return new Prompt('modify', options, refined => Request_Medusa(refined, true));
+
+ if(PromptQuality && +options.quality > 0)
+ PromptValues.QualityID = +options.quality;
+ if(PromptLocation && options.location)
+ PromptValues.StoragePath = JSON.parse(__CONFIG__.medusaStoragePaths).map(item => item.id == options.location? item: null).filter(n => n)[0].path.replace(/\\/g, '\\\\');
+
+ new Notification('info', `Sending "${ options.title }" to Medusa`, 3000);
+
+ chrome.runtime.sendMessage({
+ type: 'PUSH_MEDUSA',
+ url: `${ __CONFIG__.medusaURL }api/v2/series`,
+ root: `${ __CONFIG__.medusaURL }api/v2/`,
+ token: __CONFIG__.medusaToken,
+ StoragePath: __CONFIG__.medusaStoragePath,
+ QualityID: __CONFIG__.medusaQualityProfileId,
+ basicAuth: __CONFIG__.medusaBasicAuth,
+ title: options.title,
+ year: options.year,
+ tvdbId: options.TVDbID,
+ ...PromptValues
+ },
+ response => {
+ UTILS_TERMINAL.log('Pushing to Medusa', response);
+
+ if(response && response.error) {
+ new Notification('warning', `Could not add "${ options.title }" to Medusa: ${ response.error }`);
+ return (!response.silent && UTILS_TERMINAL.error('Error adding to Medusa: ' + String(response.error), response.location, response.debug));
+ } else if(response && response.success) {
+ let title = options.title.replace(/\&/g, 'and').replace(/\s+/g, '-').replace(/[^\w\-]+/g, '').replace(/\-{2,}/g, '-').toLowerCase(),
+ TVDbID = options.TVDbID || response.tvdbId;
+
+ CAUGHT.bump({ TVDbID });
+
+ UTILS_TERMINAL.LOG('Successfully pushed', options);
+ new Notification('update', `Added "${ options.title }" to Medusa`, 7000, () => window.open(`${__CONFIG__.medusaURL}home/displayShow?indexername=tvdb&seriesid=${options.TVDbID}`, '_blank'));
+ } else {
+ new Notification('warning', `Could not add "${ options.title }" to Medusa: Unknown Error`);
+ (!(response && response.silent) && UTILS_TERMINAL.error('Error adding to Medusa: ' + String(response)));
+ }
+ }
+ );
+ }
+
+ // TV Shows
+ function Request_SickBeard(options, prompted) {
+ if(!options.TVDbID)
+ return (!prompted)? new Notification(
+ 'warning',
+ 'Stopped adding to Sick Beard: No TVDb ID'
+ ): null;
+
+ let PromptValues = {},
+ { PromptQuality, PromptLocation } = __CONFIG__;
+
+ if(!prompted && (PromptQuality || PromptLocation))
+ return new Prompt('modify', options, refined => Request_SickBeard(refined, true));
+
+ if(PromptQuality && +options.quality > 0)
+ PromptValues.QualityID = +options.quality;
+ if(PromptLocation && +options.location >= 0)
+ PromptValues.StoragePath = JSON.parse(__CONFIG__.sickBeardStoragePaths)[+options.location].path.replace(/\\/g, '\\\\');
+
+ new Notification('info', `Sending "${ options.title }" to Sick Beard`, 3000);
+
+ COMPRESS = options.UseLZW;
+ CAUGHT = __CONFIG__.__caught;
+ CAUGHT = JSON.parse(COMPRESS? iBWT(decompress(CAUGHT)): CAUGHT);
+
+ chrome.runtime.sendMessage({
+ type: 'PUSH_SICKBEARD',
+ url: `${ __CONFIG__.sickBeardURL }api/${ __CONFIG__.sickBeardToken }/`,
+ token: __CONFIG__.sickBeardToken,
+ StoragePath: __CONFIG__.sickBeardStoragePath,
+ QualityID: __CONFIG__.sickBeardQualityProfileId,
+ basicAuth: __CONFIG__.sickBeardBasicAuth,
+ title: options.title,
+ year: options.year,
+ tvdbId: options.TVDbID,
+ exists: !!~CAUGHT.tvdb.indexOf(options.TVDbID),
+ ...PromptValues
+ },
+ response => {
+ UTILS_TERMINAL.log('Pushing to Sick Beard', response);
+
+ if(response && response.error) {
+ new Notification('warning', `Could not add "${ options.title }" to Sick Beard: ${ response.error }`);
+ return (!response.silent && UTILS_TERMINAL.error('Error adding to Sick Beard: ' + String(response.error), response.location, response.debug));
+ } else if(response && response.success) {
+ let title = options.title.replace(/\&/g, 'and').replace(/\s+/g, '-').replace(/[^\w\-]+/g, '').replace(/\-{2,}/g, '-').toLowerCase(),
+ TVDbID = options.TVDbID || response.tvdbId;
+
+ CAUGHT.bump({ TVDbID });
+
+ UTILS_TERMINAL.LOG('Successfully pushed', options);
+ new Notification('update', `Added "${ options.title }" to Sick Beard`, 7000, () => window.open(`${__CONFIG__.sickBeardURL}home/displayShow?show=${ TVDbID }`, '_blank'));
+ } else {
+ new Notification('warning', `Could not add "${ options.title }" to Sick Beard: Unknown Error`);
+ (!(response && response.silent) && UTILS_TERMINAL.error('Error adding to Sick Beard: ' + String(response)));
+ }
+ }
+ );
+ }
+
+ // make the button
+ // ( PERSISTENT, { HEADER_CLASSES } )
+ let MASTER_BUTTON;
+ function RenderButton(persistent, headers = {}) {
+ let existingButtons = document.queryBy('.web-to-plex-button'),
+ firstButton = existingButtons.first;
+
+ if(existingButtons.length && !persistent)
+ [].slice.call(existingButtons).forEach(button => button.remove());
+ else if(persistent && firstButton !== null && firstButton !== undefined)
+ return firstButton;
+
+ let { __theme } = __CONFIG__;
+
+ let ThemeClasses = JSON.parse(COMPRESS? iBWT(decompress(__theme)): __theme),
+ HeaderClasses = [],
+ ParsedAttributes = {};
+
+ // Theme(s)
+ if(!ThemeClasses.length)
+ ThemeClasses = '';
+ else
+ ThemeClasses = '.' + ThemeClasses.join('.');
+
+ ThemeClasses = ThemeClasses.split('.').filter(v => {
+ let R = RegExp;
+
+ if(/([^=]+?)=([^.]+?)/.test(v)) {
+ ParsedAttributes[R.$1] = R.$2;
+
+ return false;
+ }
+
+ return true;
+ }).join('.');
+
+ // Header(s)
+ for(let header in headers)
+ if(headers[header])
+ HeaderClasses.push( header );
+
+ if(!HeaderClasses.length)
+ HeaderClasses = '';
+ else
+ HeaderClasses = '.' + HeaderClasses.join('.');
+
+ //
+ let button =
+ furnish(`button.show.closed.floating.web-to-plex-button${HeaderClasses}${ThemeClasses}`, {
+ ...ParsedAttributes,
+ onmouseenter: event => {
+ let self = event.target;
+
+ self.classList.remove('closed');
+ self.classList.add('open', 'animate');
+ },
+ onmouseleave: event => {
+ let self = event.target;
+
+ self.classList.remove('open', 'animate');
+ self.classList.add('closed');
+ },
+ style: `background-image: url(${ IMG_URL.noise_background }), url(${ IMG_URL.background }); background-size: auto, cover;`
+ },
+ //
+ furnish('ul', {},
+ //
+ furnish('li#wtp-list-name.list-name', {},
+ furnish('a.list-action', { tooltip: 'Web to Plex' }, furnish(`img[alt=Web to Plex]`, { src: IMG_URL.icon_48 }))
+ ),
+
+ furnish('li#wtp-plexit.list-item', {
+ tooltip: 'Open Plex It!',
+ onmouseup: event => {
+ let self = event.target, parent = button;
+
+ (d=>{let s=d.createElement('script'),h=d.querySelector('head');s.type='text/javascript';s.src='//ephellon.github.io/plex.it.js';h.appendChild(s)})(document);
+ }
+ },
+ furnish('img[alt=Favorite]', { src: IMG_URL.plexit_icon_48, onmouseup: event => event.target.parentElement.click() }) //
+ ),
+
+ furnish('li#wtp-hide.list-item', {
+ tooltip: 'Hide Web to Plex',
+ onmouseup: event => {
+ let self = $('#wtp-hide').first, state = self.getAttribute('state') || 'show';
+
+ button.classList.remove(state);
+
+ self.setAttribute('tooltip', state.toCaps() + ' Web to Plex');
+
+ let img = self.querySelector('img');
+
+ img && (img.src = state == 'show'? IMG_URL.show_icon_48: IMG_URL.hide_icon_48);
+
+ if(state == 'show') {
+ state = 'hide';
+ } else {
+ state = 'show';
+ }
+
+ button.classList.add(state);
+ self.setAttribute('state', state);
+ }
+ },
+ furnish('img[alt=Hide]', { src: IMG_URL.hide_icon_48, onmouseup: event => event.target.parentElement.click() }) //
+ ),
+
+ furnish('li#wtp-refresh.list-item', {
+ tooltip: 'Reload Web to Plex',
+ onmouseup: event => {
+ let self = event.target, parent = button;
+
+ if(init instanceof Function)
+ button.setAttribute('class', 'closed floating web-to-plex-button restarting'), button.onmouseenter = button.onmouseleave = null, button.querySelector('.list-action').setAttribute('tooltip', 'Restarting...'), setTimeout(() => (init && !RUNNING? (init(), RUNNING = true): RUNNING = false), 500);
+ else
+ new Notification('warning', "Couldn't reload. Please refresh the page.");
+ }
+ },
+ furnish('img[alt=Reload]', { src: IMG_URL.reload_icon_48, onmouseup: event => event.target.parentElement.click() }) //
+ ),
+
+ furnish('li#wtp-options.list-item', {
+ tooltip: 'Open settings',
+ onmouseup: event => {
+ let self = event.target, parent = button;
+
+ return Options();
+ }
+ },
+ furnish('img[alt=Settings]', { src: IMG_URL.settings_icon_48, onmouseup: event => event.target.parentElement.click() }) //
+ )
+ //
+ )
+ //
+ );
+ //
+
+ document.body.appendChild(button);
+
+ return MASTER_BUTTON = button;
+ }
+
+ function UpdateButton(button, action, title, options = {}) {
+ if(!button)
+ return /* Rare, but happens: especially on failed download links sent */;
+
+ let multiple = (action == 'multiple' || options instanceof Array),
+ element = button.querySelector('.w2p-action, .list-action'),
+ delimeter = '',
+ ty = 'Item', txt = 'title', hov = 'tooltip',
+ em = /^(tt|0)?$/i,
+ tv = /tv[\s-]?|shows?|series/i;
+
+ if(!element) {
+ element = button;
+ button = element.parentElement;
+ };
+
+ Update('SEARCH_FOR', { ...options, button });
+
+ /* Handle a list of items */
+ if(multiple) {
+ options = [].slice.call(options);
+
+ let saved_options = [], // a list of successful searches (not on Plex)
+ len = options.length,
+ s = (len == 1? '': 's'),
+ t = [];
+
+ for(let index = 0; index < len; index++) {
+ let option = options[index];
+
+ // Skip empty entries
+ if(!option || !option.type || !option.title)
+ continue;
+
+ // Skip queued entries
+ if(
+ !!~CAUGHT.imdb.indexOf(option.IMDbID) ||
+ !!~CAUGHT.tmdb.indexOf(option.TMDbID) ||
+ !!~CAUGHT.tvdb.indexOf(option.TVDbID)
+ )
+ continue;
+
+ // the action should be an array
+ // we'll give the button a list of links to engage, so make it snappy!
+ let url = `#${ option.IMDbID || 'tt' }-${ option.TMDbID | 0 }-${ option.TVDbID | 0 }`;
+
+ /* Failed */
+ if(/#tt-0-0/i.test(url))
+ continue;
+
+ saved_options.push(option);
+ t.push(option.title);
+ }
+
+ t = t.join(', ');
+ t = t.length > 24? t.slice(0, 21).replace(/\W+$/, '') + '...': t;
+
+ element.ON_CLICK = e => {
+ e.preventDefault();
+
+ let self = e.target, tv = /tv[\s-]?|shows?|series/i, fail = 0,
+ options = JSON.parse(decode(button.getAttribute('saved_options')));
+
+ for(let index = 0, length = options.length, option; index < length; index++) {
+ option = options[index];
+
+ try {
+ if(__CONFIG__.usingOmbi)
+ Request_Ombi(option, true);
+ else if(__CONFIG__.usingWatcher && !tv.test(option.type))
+ Request_Watcher(option, true);
+ else if(__CONFIG__.usingRadarr && !tv.test(option.type))
+ Request_Radarr(option, true);
+ else if(__CONFIG__.usingCouchPotato && !tv.test(option.type))
+ Request_CouchPotato(option, true);
+ else if(__CONFIG__.usingSonarr && tv.test(option.type))
+ Request_Sonarr(option, true);
+ else if(__CONFIG__.usingMedusa && tv.test(option.type))
+ Request_Medusa(option, true);
+ else if(__CONFIG__.usingSickBeard && tv.test(option.type))
+ Request_SickBeard(option, true);
+
+ button.classList.replace('wtp--download', 'wtp--queued');
+ } catch(error) {
+ UTILS_TERMINAL.error(`Failed to get "${ option.title }" (Error #${ ++fail })`)
+ }
+ }
+ NOTIFIED = false;
+
+ if(fail)
+ new Notification('error', `Failed to grab ${ fail } item${fail==1?'':'s'}`);
+ };
+
+ button.setAttribute('saved_options', encode(JSON.stringify(saved_options)));
+ element.addEventListener('click', e => (AUTO_GRAB.ENABLED && AUTO_GRAB.LIMIT > options.length)? element.ON_CLICK(e): new Prompt('select', options, o => { button.setAttribute('saved_options', encode(JSON.stringify(o))); element.ON_CLICK(e) }));
+
+ element.setAttribute(hov, `Grab ${len} new item${s}: ${ t }`);
+ button.classList.add(saved_options.length || len? 'wtp--download': 'wtp--error');
+ } else {
+ /* Handle a single item */
+
+ if(!options || !options.type || !options.title)
+ return;
+
+ let empty = (em.test(options.IMDbID) && em.test(options.TMDbID) && em.test(options.TVDbID)),
+ nice_title = `${options.title.toCaps()}${options.year? ` (${options.year})`: ''}`;
+
+ if(options) {
+ ty = (/^(cine(ma)?|films?|movies?|theat[re]{2})$/i.test(options.type)? 'Movie': 'TV Show');
+ txt = options.txt || txt;
+ hov = options.hov || hov;
+ }
+
+ if(action == 'found') {
+ element.href = Request_PlexURL(__CONFIG__.server.id, options.key);
+ element.setAttribute(hov, `Watch "${options.title} (${options.year})" on Plex`);
+ button.classList.add('wtp--found');
+
+ new Notification('success', `Watch "${ nice_title }"`, 7000, e => element.click(e));
+ } else if(action == 'downloader' || options.remote) {
+
+ switch(options.remote) {
+ /* Vumoo & GoStream */
+ case 'plex':
+ case 'oload':
+ case 'fembed':
+ case 'consistent':
+ case 'gounlimited':
+ let href = options.href, path = '';
+
+ if(__CONFIG__.usingOmbi) {
+ path = '';
+ } else if(__CONFIG__.usingWatcher && !tv.test(options.type)) {
+ path = '';
+ } else if(__CONFIG__.usingRadarr && !tv.test(options.type)) {
+ path = __CONFIG__.radarrStoragePath;
+ } else if(__CONFIG__.usingSonarr && tv.test(options.type)) {
+ path = __CONFIG__.sonarrStoragePath;
+ } else if(__CONFIG__.usingMedusa && tv.test(options.type)) {
+ path = __CONFIG__.medusaStoragePath;
+ } else if(__CONFIG__.usingSickBeard && tv.test(options.type)) {
+ path = __CONFIG__.sickBeardStoragePath;
+ } else if(__CONFIG__.usingCouchPotato) {
+ path = '';
+ }
+
+ element.href = `#${ options.IMDbID || 'tt' }-${ options.TMDbID | 0 }-${ options.TVDbID | 0 }`;
+
+ button.classList.remove('wtp--queued');
+ button.classList.add('wtp--download');
+
+ element.removeEventListener('click', element.ON_CLICK);
+ element.addEventListener('click', element.ON_DOWNLOAD = e => {
+ e.preventDefault();
+
+ Update('DOWNLOAD_FILE', { ...options, button, href, path });
+ new Notification('update', 'Opening prompt (may take a while)...');
+ });
+
+ element.setAttribute(hov, `Download "${ nice_title }" | ${ty}`);
+ Update('SAVE_AS', { ...options, button, href, path });
+ new Notification('update', `"${ nice_title }" can be downloaded`, 7000, e => element.click(e));
+ return;
+
+
+ /* Default & Error */
+ default:
+ let url = `#${ options.IMDbID || 'tt' }-${ options.TMDbID | 0 }-${ options.TVDbID | 0 }`;
+
+ /* Failed */
+ if(/#tt-0-0/i.test(url))
+ return UpdateButton(button, 'notfound', title, options);
+
+ element.href = url;
+ button.classList.add('wtp--download');
+ element.addEventListener('click', element.ON_CLICK = e => {
+ e.preventDefault();
+ try {
+ if(__CONFIG__.usingOmbi)
+ Request_Ombi(options);
+ else if(__CONFIG__.usingWatcher && !tv.test(options.type))
+ Request_Watcher(options);
+ else if(__CONFIG__.usingRadarr && !tv.test(options.type))
+ Request_Radarr(options);
+ else if(__CONFIG__.usingCouchPotato && !tv.test(options.type))
+ Request_CouchPotato(options);
+ else if(__CONFIG__.usingSonarr && tv.test(options.type))
+ Request_Sonarr(options);
+ else if(__CONFIG__.usingMedusa && tv.test(options.type))
+ Request_Medusa(options);
+ else if(__CONFIG__.usingSickBeard && tv.test(options.type))
+ Request_SickBeard(options);
+
+ button.classList.replace('wtp--download', 'wtp--queued');
+ } catch(error) {
+ throw error;
+ }
+
+ });
+ }
+ NOTIFIED = false;
+
+ element.setAttribute(hov, `Add "${ nice_title }" | ${ty}`);
+ element.style.removeProperty('display');
+ } else if(action == 'notfound' || action == 'error' || empty) {
+ element.removeAttribute('href');
+
+ empty = !(options && options.title);
+
+ if(empty)
+ element.setAttribute(hov, `${ty || 'Item'} not found`);
+ else
+ element.setAttribute(hov, `"${ nice_title }" was not found`);
+
+ button.classList.remove('wtp--found');
+ button.classList.add('wtp--error');
+ }
+
+ if((action == 'downloader') && (!!~CAUGHT.imdb.indexOf(options.IMDbID) || !!~CAUGHT.tmdb.indexOf(options.TMDbID) || !!~CAUGHT.tvdb.indexOf(options.TVDbID))) {
+ element.setAttribute(hov, `Modify "${ nice_title }" | ${ty}`);
+
+ button.classList.remove('wtp--found');
+ button.classList.add('wtp--queued');
+ }
+
+ element.id = options? `${options.IMDbID || 'tt'}-${options.TMDbID | 0}-${options.TVDbID | 0}`: 'tt-0-0';
+ }
+ }
+
+ // Find media on Plex
+ async function FindMediaItems(options = [], button) {
+ if(!(options.length && button))
+ return;
+
+ let results = [],
+ length = options.length,
+ queries = (FindMediaItems.queries = FindMediaItems.queries || {});
+
+ FindMediaItems.OPTIONS = options;
+
+ let query = JSON.stringify(options);
+
+ query = (queries[query] = queries[query] || {});
+
+ if(query.running === true)
+ return;
+ else if(query.results) {
+ let { results, multiple, items } = query;
+
+ new Notification('update', `Welcome back. ${ multiple } new ${ items } can be grabbed`, 7000, (event, target = button.querySelector('.list-action')) => target.click({ ...event, target }));
+
+ if(multiple)
+ UpdateButton(button, 'multiple', `Download ${ multiple } ${ items }`, results);
+
+ return;
+ }
+
+ query.running = true;
+
+ new Notification('info', `Processing ${ length } item${ 's'[+(length === 1)] || '' }...`);
+
+ for(let index = 0, option, opt; index < length; index++) {
+ let { IMDbID, TMDbID, TVDbID } = (option = await options[index]);
+
+ opt = { name: option.title, title: option.title, year: option.year, image: options.image, type: option.type, imdb: IMDbID, IMDbID, tmdb: TMDbID, TMDbID, tvdb: TVDbID, TVDbID };
+
+ try {
+ await Request_Plex(option)
+ .then(async({ found, key }) => {
+ if(found) {
+ // ignore found items, we only want new items
+ } else {
+ option.field = 'original_title';
+
+ return await Request_Plex(option)
+ .then(({ found, key }) => {
+ if(found) {
+ // ignore found items, we only want new items
+ } else {
+ let available = (__CONFIG__.usingOmbi || __CONFIG__.usingWatcher || __CONFIG__.usingRadarr || __CONFIG__.usingSonarr || __CONFIG__.usingMedusa || __CONFIG__.usingSickBeard || __CONFIG__.usingCouchPotato),
+ action = (available? 'downloader': 'notfound'),
+ title = available?
+ 'Not on Plex (download available)':
+ 'Not on Plex (download not available)';
+
+ results.push({ ...opt, found: false, status: action });
+ }
+ });
+ }
+ })
+ .catch(error => { throw error });
+ } catch(error) {
+ UTILS_TERMINAL.error('Request to Plex failed: ' + String(error));
+ // new Notification('error', 'Failed to query item #' + (index + 1));
+ }
+ }
+
+ results = results.filter(v => v.status == 'downloader');
+
+ let img = furnish('img', { title: 'Add to Plex It!', src: IMG_URL.plexit_icon_48, onmouseup: event => {let frame = document.querySelector('#plexit-bookmarklet-frame'); frame.src = frame.src.replace(/(#plexit:.*)?$/, '#plexit:' + event.target.parentElement.getAttribute('data'))} }),
+ po, pi = furnish('li#plexit.list-item', { data: encode(JSON.stringify(results)) }, img),
+ op = document.querySelector('#wtp-plexit');
+
+ if(po = button.querySelector('#plexit'))
+ po.remove();
+ try {
+ button.querySelector('ul').insertBefore(pi, op);
+ } catch(e) { /* Don't do anything */ }
+
+ let multiple = results.length,
+ items = multiple == 1? 'item': 'items';
+
+ new Notification('update', `Done. ${ multiple } new ${ items } can be grabbed`, 7000, (event, target = button.querySelector('.list-action')) => target.click({ ...event, target }));
+
+ query.running = false;
+ query.results = results;
+ query.multiple = multiple;
+ query.items = items;
+
+ if(multiple)
+ UpdateButton(button, 'multiple', `Download ${ multiple } ${ items }`, results);
+ }
+
+ async function FindMediaItem(options = {}) {
+ if(!(options.title))
+ return;
+
+ let { IMDbID, TMDbID, TVDbID } = options;
+
+ TMDbID = +TMDbID;
+ TVDbID = +TVDbID;
+
+ let opt = { name: options.title, year: options.year, image: options.image || IMG_URL.nil, type: options.type, imdb: IMDbID, IMDbID, tmdb: TMDbID, TMDbID, tvdb: TVDbID, TVDbID },
+ op = document.querySelector('#wtp-plexit'),
+ img = (options.image)?
+ furnish('div', { tooltip: 'Add to Plex It!', style: `background: url(${ IMG_URL.plexit_icon_16 }) top right/60% no-repeat, #0004 url(${ opt.image }) center/contain no-repeat; height: 48px; width: 34px;`, draggable: true, onmouseup: event => {let frame = document.querySelector('#plexit-bookmarklet-frame'); frame.src = frame.src.replace(/(#plexit:.*)?$/, '#plexit:' + event.target.parentElement.getAttribute('data'))} }):
+ furnish('img', { title: 'Add to Plex It!', src: IMG_URL.plexit_icon_48, onmouseup: event => {let frame = document.querySelector('#plexit-bookmarklet-frame'); frame.src = frame.src.replace(/(#plexit:.*)?$/, '#plexit:' + event.target.parentElement.getAttribute('data'))} });
+
+ FindMediaItem.OPTIONS = options;
+
+ try {
+ return Request_Plex(options).then(({ found, key }) => {
+ if(found) {
+ UpdateButton(options.button, 'found', 'On Plex', { ...options, key });
+ opt = { ...opt, url: options.button.href, found: true, status: 'found' };
+
+ let po, pi = furnish('li#plexit.list-item', { data: encode(JSON.stringify(opt)) }, img);
+
+ if(po = options.button.querySelector('#plexit'))
+ po.remove();
+ try {
+ options.button.querySelector('ul').insertBefore(pi, op);
+ } catch(e) { /* Don't do anything */ }
+ } else {
+ options.field = 'original_title';
+
+ return Request_Plex(options).then(({ found, key }) => {
+ if(found) {
+ UpdateButton(options.button, 'found', 'On Plex', { ...options, key });
+ opt = { ...opt, url: options.button.href, found: true, status: 'found' };
+
+ let po, pi = furnish('li#plexit.list-item', { data: encode(JSON.stringify(opt)) }, img);
+
+ if(po = options.button.querySelector('#plexit'))
+ po.remove();
+ try {
+ options.button.querySelector('ul').insertBefore(pi, op);
+ } catch(e) { /* Don't do anything */ }
+ } else {
+ let available = (__CONFIG__.usingOmbi || __CONFIG__.usingWatcher || __CONFIG__.usingRadarr || __CONFIG__.usingSonarr || __CONFIG__.usingMedusa || __CONFIG__.usingSickBeard || __CONFIG__.usingCouchPotato),
+ action = (available ? 'downloader' : 'notfound'),
+ title = available ?
+ 'Not on Plex (download available)':
+ 'Not on Plex (download not available)';
+
+ UpdateButton(options.button, action, title, options);
+ opt = { ...opt, found: false, status: action };
+
+ let po, pi = furnish('li#plexit.list-item', { data: encode(JSON.stringify(opt)) }, img);
+
+ if(po = options.button.querySelector('#plexit'))
+ po.remove();
+ if(!!~[].slice.call(options.button.querySelector('ul').children).indexOf(op))
+ try {
+ options.button.querySelector('ul').insertBefore(pi, op);
+ } catch(e) { /* Don't do anything */ }
+ }
+
+ return found;
+ });
+ }
+
+ return found;
+ })
+ .catch(error => { throw error });
+ } catch(error) {
+ return UpdateButton(
+ options.button,
+ 'error',
+ 'Request to Plex Media Server failed',
+ options
+ ),
+ UTILS_TERMINAL.error(`Request to Plex failed: ${ String(error) }`),
+ false;
+ // new Notification('Failed to communicate with Plex');
+ }
+ }
+
+ function Request_Plex(options) {
+ if(!(__CONFIG__.plexURL && __CONFIG__.plexToken) || __CONFIG__.IGNORE_PLEX)
+ return new Promise((resolve, reject) => resolve({ found: false, key: null }));
+
+ return new Promise((resolve, reject) => {
+ // Sanitize the object
+ options = JSON.parse( JSON.stringify(options) );
+
+ UTILS_TERMINAL.LOG('Searching for item on Plex', options);
+
+ chrome.runtime.sendMessage({
+ type: 'SEARCH_PLEX',
+ options,
+ serverConfig: __CONFIG__.server
+ },
+ response =>
+ (response && response.error)?
+ reject(response.error):
+ (!response)?
+ reject(new Error(`Unknown error: ${ response }`)):
+ resolve(response)
+ );
+ });
+ }
+
+ function Request_PlexURL(PlexUIID, key) {
+ return __CONFIG__.plexURL.replace(RegExp(`\/(${ __CONFIG__.server.id })?$`), `/web#!/server/` + PlexUIID) + `/details?key=${encodeURIComponent( key )}`;
+ }
+
+ /* Listen for events */
+ chrome.runtime.onMessage.addListener(async(request, sender) => {
+ UTILS_TERMINAL.LOG(`Listener event [${ request.instance_type }#${ request[request.instance_type.toLowerCase()] }]:`, request);
+
+ let data = request.data,
+ LOCATION = `${ request.name || 'anonymous' } @ instance ${ request.instance }`,
+ PARSING_ERROR = `Can't parse missing information. ${ LOCATION }`,
+ BUTTON_ERROR = `The button failed to render. ${ LOCATION }`,
+ EMPTY_REQUEST = `The given request is empty. ${ LOCATION }`;
+
+ if(!data)
+ return UTILS_TERMINAL.WARN(EMPTY_REQUEST);
+ let button = RenderButton();
+
+ if(!button)
+ return UTILS_TERMINAL.WARN(BUTTON_ERROR);
+ button.classList.remove('sleeper');
+
+ switch(request.type) {
+ case 'POPULATE':
+ if(data instanceof Array) {
+ for(let index = 0, length = data.length, item; index < length; index++)
+ if(!(item = data[index]) || !item.type)
+ data.splice(index, 1, null);
+
+ data = data.filter(value => value !== null && value !== undefined);
+
+ for(let index = 0, length = data.length, item; index < length; index++) {
+ let { image, type, title, year, IMDbID, TMDbID, TVDbID } = (item = data[index]);
+
+ if(!item.title || !item.type)
+ continue;
+
+ let Db = await Identify(item);
+
+ IMDbID = IMDbID || Db.imdb || 'tt';
+ TMDbID = TMDbID || Db.tmdb || 0;
+ TVDbID = TVDbID || Db.tvdb || 0;
+
+ title = title || Db.title;
+ year = +(year || Db.year || 0);
+
+ data.splice(index, 1, { type, title, year, image, button, IMDbID, TMDbID, TVDbID });
+ }
+
+ if(!data.length)
+ return UTILS_TERMINAL.ERROR(PARSING_ERROR);
+ else
+ FindMediaItems(data, button);
+ } else {
+ if(!data || !data.title || !data.type)
+ return UTILS_TERMINAL.ERROR(PARSING_ERROR);
+
+ let { image, type, title, year, IMDbID, TMDbID, TVDbID } = data;
+ let Db = await Identify(data);
+
+ IMDbID = IMDbID || Db.imdb || 'tt';
+ TMDbID = TMDbID || Db.tmdb || 0;
+ TVDbID = TVDbID || Db.tvdb || 0;
+
+ title = title || Db.title;
+ year = +(year || Db.year || 0);
+
+ let found = await FindMediaItem({ type, title, year, image, button, IMDbID, TMDbID, TVDbID });
+ Update('FOUND', { ...request, found }, true);
+ }
+ return true;
+
+ case 'INITIALIZE':
+ UTILS_TERMINAL.LOG('Told to reinitialize...');
+ init && init();
+ return true;
+
+ case 'NO_RENDER':
+ UTILS_TERMINAL.WARN('Told to stop rendering...');
+ document.queryBy('.web-to-plex-button').map(e => e.remove());
+ return true;
+
+ default:
+ // UTILS_TERMINAL.WARN(`Unknown utils event [${ request.type }]`);
+ return false;
+ }
+ });
+
+ /* Listen for Window events - from iframes, etc. */
+ top.addEventListener('message', async request => {
+ try {
+ request = request.data;
+
+ switch(request.type) {
+ case 'SEND_VIDEO_LINK':
+ let options = { ...FindMediaItem.OPTIONS, href: request.href, remote: request.from };
+
+ UTILS_TERMINAL.LOG(`Download Event [${ options.remote }]:`, options);
+
+ UpdateButton(MASTER_BUTTON, 'downloader', 'Download', options);
+ return true;
+
+ case 'NOTIFICATION':
+ let { state, text, timeout = 7000, callback = () => {}, requiresClick = true } = request.data;
+ new Notification(state, text, timeout, callback, requiresClick);
+ return true;
+
+ case 'PERMISSION':
+ let { data } = request,
+ { instance } = data;
+
+ if(typeof instance != 'string' || !/[\da-z]{64,}/i.test(instance))
+ throw `Incorrect instance [${ instance.slice(0, 7) }]`;
+
+ if(typeof data.allowed == 'boolean') {
+ ALLOWED = data.allowed;
+ PERMISS = data.allotted;
+
+ await ParsedOptions();
+
+ (init && !RUNNING? (init(), RUNNING = true): RUNNING = false);
+ } else {
+ UTILS_TERMINAL.WARN('Permission Request:', data);
+ new Prompt('permission', data);
+ }
+ return true;
+
+ default:
+ // UTILS_TERMINAL.WARN(`Unknown utils event [${ request.type }]`);
+ return false;
+ }
+ } catch(error) {
+ new Notification('error', `Unable to use downloader: ${ String(error) }`);
+ throw error
+ }
+ });
+
+ // create the sleeping button
+ wait(() => document.readyState === 'complete', () => RenderButton(null, { sleeper: true }));
+
+})(new Date);
+
+/* Helpers */
+/* BWT Sorting Algorithm */
+function BWT(string = '') {
+ if(/^[\x32]*$/.test(string))
+ return '';
+
+ let _a = `\u0001${ string }`,
+ _b = `\u0001${ string }\u0001${ string }`,
+ p_ = [];
+
+ for(let i = 0; i < _a.length; i++)
+ p_.push(_b.slice(i, _a.length + i));
+
+ p_ = p_.sort();
+
+ return p_.map(P => P.slice(-1)[0]).join('');
+}
+
+/* BWT Desorting Algorithm */
+function iBWT(string = '') {
+ if(/^[\x32]*$/.test(string))
+ return '';
+
+ let a = string.split('');
+
+ let O = q => {
+ let x = 0;
+ for(let i = 0; i < a.length; i++)
+ if(a[i] < q)
+ x++;
+ return x;
+ };
+
+ let C = (n, q) => {
+ let x = 0;
+ for(let i = 0; i < n; i++)
+ if(a[i] === q)
+ x++;
+ return x;
+ };
+
+ let b = 0,
+ c = '',
+ d = a.length + 1;
+
+ while(a[b] !== '\u0001' && d--) {
+ c = a[b] + c;
+ b = O(a[b]) + C(b, a[b]);
+ }
+
+ return c;
+}
+
+/* LZW Compression Algorithm */
+function compress(string = '') {
+ let dictionary = {},
+ phrases = (string + ''),
+ phrase = phrases[0],
+ medium = [],
+ output = [],
+ index = 255,
+ character;
+
+ let at = (w = phrase, d = dictionary) =>
+ (w.length > 1)?
+ d[`@${ w }`]:
+ w.charCodeAt(0);
+
+ for(let i = 1, l = phrases.length; i < l; i++)
+ if(dictionary[`@${ phrase }${ character = phrases[i] }`] !== undefined) {
+ phrase += character;
+ } else {
+ medium.push(at(phrase));
+ dictionary[`@${ phrase }${ character }`] = index++;
+ phrase = character;
+ }
+ medium.push(at(phrase));
+
+ for(let i = 0, l = medium.length; i < l; i++)
+ output.push(String.fromCharCode(medium[i]));
+
+ return output.join('');
+}
+
+/* LZW Decompression Algorithm */
+function decompress(string = '') {
+ let dictionary = {},
+ phrases = (string + ''),
+ character = phrases[0],
+ word = {
+ now: '',
+ last: character,
+ },
+ output = [character],
+ index = 255;
+
+ for(let i = 1, l = phrases.length, code; i < l; i++) {
+ code = phrases.charCodeAt(i);
+
+ if(code < 255)
+ word.now = phrases[i];
+ else if((word.now = dictionary[`@${ code }`]) === undefined)
+ word.now = word.last + character;
+
+ output.push(word.now);
+ character = word.now[0];
+ dictionary[`@${ index++ }`] = word.last + character;
+ word.last = word.now;
+ }
+
+ return output.join('');
+}
+
+function wait(on, then) {
+ if(on && ((on instanceof Function && on()) || true))
+ then && then();
+ else
+ setTimeout(() => wait(on, then), 50);
+}
+
+// the custom "on location change" event
+function watchlocationchange(subject) {
+ let locationchangecallbacks = watchlocationchange.locationchangecallbacks;
+
+ watchlocationchange[subject] = watchlocationchange[subject] || location[subject];
+
+ if(watchlocationchange[subject] != location[subject]) {
+ let from = watchlocationchange[subject],
+ to = location[subject],
+ properties = { from, to },
+ sign = code => (code + '').replace(/\W+/g, '').toLowerCase();
+
+ watchlocationchange[subject] = location[subject];
+
+ for(let index = 0, length = locationchangecallbacks.length, callback, exists, signature; length > 0 && index < length; index++) {
+ callback = locationchangecallbacks[index];
+ exists = locationchangecallbacks.exists[signature = sign(callback)];
+
+ let event = new Event('locationchange', { bubbles: true });
+
+ if(!exists && typeof callback == 'function') {
+ /* The eventlistener does not exist */
+ window.addEventListener('beforeunload', event => {
+ event.preventDefault(false);
+ callback({ event, ...properties });
+ });
+ } else {
+ /* The eventlistener already exists */
+ callback({ event, ...properties });
+ }
+
+ open(to, '_self');
+ }
+ }
+}
+watchlocationchange.locationchangecallbacks = watchlocationchange.locationchangecallbacks || [];
+watchlocationchange.locationchangecallbacks.exists = watchlocationchange.locationchangecallbacks.exists || {};
+
+if(!('onlocationchange' in window))
+ Object.defineProperty(window, 'onlocationchange', {
+ set: callback => {
+ if(typeof callback != 'function')
+ return null;
+
+ let signature = (callback + '').replace(/\W+/g, '').toLowerCase();
+
+ if(!watchlocationchange.locationchangecallbacks.exists[signature]) {
+ watchlocationchange.locationchangecallbacks.exists[signature] = true;
+
+ return watchlocationchange.locationchangecallbacks.push(callback);
+ }
+ return null;
+ },
+ get: () => watchlocationchange.locationchangecallbacks
+ });
+
+watchlocationchange.onlocationchangeinterval = watchlocationchange.onlocationchangeinterval || setInterval(() => watchlocationchange('href'), 1000);
+// at least 1s is needed to properly fire the event ._.
+
+String.prototype.toCaps = String.prototype.toCaps || function toCaps(all) {
+ /** Titling Caplitalization
+ * Articles: a, an, & the
+ * Conjunctions: and, but, for, nor, or, so, & yet
+ * Prepositions: across, after, although, at, because, before, between, by, during, from, if, in, into, of, on, to, through, under, with, & without
+ */
+ let array = this.toLowerCase(),
+ titles = /(?!^|(?:an?|the)\s+)\b(a([st]|nd?|cross|fter|lthough)?|b(e(cause|fore|tween)?|ut|y)|during|from|in(to)?|[io][fn]|[fn]?or|the|[st]o|through|under|with(out)?|yet)(?!\s*$)\b/gi,
+ cap_exceptions = /([\|\"\(]\s*[a-z]|[\:\.\!\?]\s+[a-z]|(?:^\b|[^\'\-\+]\b)[^aeiouy\d\W]+\b)/gi, // Punctuation exceptions, e.g. "And not I"
+ all_exceptions = /\b((?:ww)?(?:m{1,4}(?:c?d(?:c{0,3}(?:x?l(?:x{0,3}(?:i?vi{0,3})?)?)?)?)?|c?d(?:c{0,3}(?:x?l(?:x{0,3}(?:i?vi{0,3})?)?)?)?|c{1,3}(?:x?l(?:x{0,3}(?:i?vi{0,3})?)?)?|x?l(?:x{0,3}(?:i?vi{0,3})?)?|x{1,3}(?:i?vi{0,3})?|i?vi{0,3}|i{1,3}))\b/gi, // Roman Numberals
+ cam_exceptions = /\b((?:mr?s|[sdjm]r|mx)|(?:adm|cm?dr?|chf|c[op][lmr]|cpt|gen|lt|mjr|sgt)|doc|hon|prof)(?:\.|\b)/gi, // Titles (Most Common?)
+ low_exceptions = /'([\w]+)/gi; // Apostrphe cases
+
+ array = array.split(/\s+/);
+
+ let index, length, string, word;
+ for(index = 0, length = array.length, string = [], word; index < length; index++) {
+ word = array[index];
+
+ if(word)
+ string.push( word[0].toUpperCase() + word.slice(1, word.length) );
+ }
+
+ string = string.join(' ');
+
+ if(!all)
+ string = string
+ .replace(titles, ($0, $1, $$, $_) => $1.toLowerCase())
+ .replace(all_exceptions, ($0, $1, $$, $_) => $1.toUpperCase())
+ .replace(cap_exceptions, ($0, $1, $$, $_) => $1.toUpperCase())
+ .replace(low_exceptions, ($0, $1, $$, $_) => $0.toLowerCase())
+ .replace(cam_exceptions, ($0, $1, $$, $_) => $1[0].toUpperCase() + $1.slice(1, $1.length).toLowerCase() + '.');
+
+ return string;
+};
+
+Object.filter = Object.filter || function filter(object, prejudice) {
+ if(!prejudice)
+ return object;
+
+ let results = {};
+
+ for(let key in object)
+ if(prejudice(key, object[key]))
+ results[key] = object[key];
+
+ return results;
+};
+
+(function(parent) {
+/* SortBy.js */
+/** Usage + Example
+ // document.queryBy( selectors )...
+
+ let index = 0;
+ // the order given is the order handled
+ document.queryBy("div:last-child, div:nth-child(2), div:first-child")
+ .forEach((element, index, array) => element.innerHTML = index + 1);
+
+ // output w/sortBySelector:
+ 3
+ 2
+ 1
+
+ // output w/o sortBySelector:
+ 1
+ 2
+ 3
+ */
+ parent.queryBy = parent.queryBy || function queryBy(selectors, container = document) {
+ // Helpers
+ let copy = array => [...array],
+ query = (SELECTORS, CONTAINER = container) => CONTAINER.querySelectorAll(SELECTORS);
+
+ // Get rid of enclosing syntaxes: [...] and (...)
+ let regexp = /(\([^\(\)]+?\)|\[[^\[\]]+?\])/g,
+ pulled = [],
+ media = [],
+ index, length;
+
+ // The index shouldn't be longer than the length of the selector's string
+ // Keep this to prevent infinite loops
+ for(index = 0, length = selectors.length; index++ < length && regexp.test(selectors);)
+ selectors = selectors.replace(regexp, ($0, $1, $$, $_) => '\b--' + pulled.push($1) + '\b');
+
+ let order = selectors.split(','),
+ dummy = copy(order),
+ output = [],
+ generations = 0,
+ cousins = 0;
+
+ // Replace those syntaxes (they were ignored)
+ for(index = 0, length = dummy.length, order = [], regexp = /[\b]--(\d+)[\b]/g; index < length; index++)
+ order.push(dummy[index].replace(regexp, ($0, $1, $$, $_) => pulled[+$1 - 1]));
+
+ // Make sure to put the elements in order
+ // Handle the :parent (pseudo) selector
+ for(index = 0, length = order.length; index < length; generations = 0, cousins = 0, index++) {
+ let selector = order[index], ancestor, cousin;
+
+ selector = selector
+ // siblings
+ .replace(/\:nth-sibling\((\d+)\)/g, ($0, $1, $$, $_) => (cousins += +$1, ''))
+ .replace(/(\:{1,2}(next-|previous-)?sibling)/g, ($0, $1, $2, $$, $_) => (cousins += ($2 == 'next'? 1: -1), ''))
+ // parents
+ .replace(/\:nth-parent\((\d+)\)/g, ($0, $1, $$, $_) => (generations -= +$1, ''))
+ .replace(/(\:{1,2}parent\b|<\s*(\s*(,|$)))/g, ($0, $$, $_) => (--generations, ''))
+ .replace(/<([^<,]+)?/g, ($0, $1, $$, $_) => (ancestor = $1, --generations, ''))
+ // miscellaneous
+ .replace(/^\s+|\s+$/g, '');
+
+ let elements = [].slice.call(query(selector)),
+ parents = [], parent,
+ siblings = [], sibling;
+
+ // Parents
+ for(; generations < 0; generations++)
+ elements = elements.map(element => {
+ let P = element, Q = (P? P.parentElement: {}), R = (Q? Q.parentElement: {}),
+ E = C => [...query(ancestor, C)],
+ F, G;
+
+ for(let I = 0, L = -generations; ancestor && !!R && !!Q && !!P && I < L; I++)
+ parent = !!~E(R).indexOf(Q)? Q: G;
+
+ for(let I = 0, L = -generations; !ancestor && !!Q && !!P && I < L; I++)
+ Q = (parent = P = Q).parentElement;
+
+ if((generations === 0 || /\*$/.test(ancestor)) && !~parents.indexOf(parent))
+ parents.push(parent);
+
+ return parent;
+ });
+
+ // Siblings
+ if(cousins === 0)
+ /* Do nothing */;
+ else if(cousins < 0)
+ for(; cousins < 0; cousins++)
+ elements = elements.map(element => {
+ let P = element, Q = (P? P.previousElementSibling: {}),
+ F, G;
+
+ for(let I = 0, L = -cousins; !!Q && !!P && I < L; I++)
+ Q = (sibling = P = Q).previousElementSibling;
+
+ if(cousins === 0 && !~siblings.indexOf(sibling))
+ siblings.push(sibling);
+
+ return sibling;
+ });
+ else
+ for(; cousins > 0; cousins--)
+ elements = elements.map(element => {
+ let P = element, Q = (P? P.nextElementSibling: {}),
+ F, G;
+
+ for(let I = 0, L = -cousins; !!Q && !!P && I > L; I--)
+ Q = (sibling = P = Q).nextElementSibling;
+
+ if(cousins === 0 && !~siblings.indexOf(sibling))
+ siblings.push(sibling);
+
+ return sibling;
+ });
+
+ media.push(parents.length? parents: elements);
+ media.push(siblings.length? siblings: elements);
+ order.splice(index, 1, selector);
+ }
+
+ // Create a continuous array from the sub-arrays
+ for(index = 1, length = media.length; index < length; index++)
+ media.splice(0, 1, copy(media[0]).concat( copy(media[index]) ));
+ output = [].slice.call(media[0]).filter( value => value );
+
+ // Remove repeats
+ for(index = 0, length = output.length, media = []; index < length; index++)
+ if(!~media.indexOf(output[index]))
+ media.push(output[index]);
+
+ let properties = { writable: false, enumerable: false, configurable: false };
+
+ Object.defineProperties(media, {
+ first: {
+ value: media[0],
+ ...properties
+ },
+ last: {
+ value: media[media.length - 1],
+ ...properties
+ },
+ child: {
+ value: index => media[index - 1],
+ ...properties
+ },
+ empty: {
+ value: !media.length,
+ ...properties
+ },
+ });
+
+ return media;
+ };
+
+/** Adopted from
+ * LICENSE: MIT (2018)
+ */
+ parent.furnish = parent.furnish || function furnish(TAGNAME, ATTRIBUTES = {}, ...CHILDREN) {
+ let u = v => v && v.length, R = RegExp, name = TAGNAME, attributes = ATTRIBUTES, children = CHILDREN;
+
+ if( !u(name) )
+ throw TypeError(`TAGNAME cannot be ${ (name === '')? 'empty': name }`);
+
+ let options = attributes.is === true? { is: true }: null;
+
+ delete attributes.is;
+
+ name = name.split(/([#\.][^#\.\[\]]+)/).filter( u );
+
+ if(name.length <= 1)
+ name = name[0].split(/^([^\[\]]+)(\[.+\])/).filter( u );
+
+ if(name.length > 1)
+ for(let n = name, i = 1, l = n.length, t, v; i < l; i++)
+ if((v = n[i].slice(1, n[i].length)) && (t = n[i][0]) == '#')
+ attributes.id = v;
+ else if(t == '.')
+ attributes.classList = [].slice.call(attributes.classList || []).concat(v);
+ else if(/\[(.+)\]/.test(n[i]))
+ R.$1.split('][').forEach(N => attributes[(N = N.replace(/\s*=\s*(?:("?)([^]*)\1)?/, '=$2').split('=', 2))[0]] = N[1] || '');
+ name = name[0];
+
+ let element = document.createElement(name, options);
+
+ if(attributes.classList instanceof Array)
+ attributes.classList = attributes.classList.join(' ');
+
+ Object.entries(attributes).forEach(
+ ([name, value]) => (/^(on|(?:(?:inner|outer)(?:HTML|Text)|textContent|class(?:List|Name)|value)$)/.test(name))?
+ (typeof value == 'string' && /^on/.test(name))?
+ (() => {
+ try {
+ /* Can't make a new function(eval) */
+ element[name] = new Function('', value);
+ } catch (__error) {
+ try {
+ /* Not a Chrome (extension) state */
+ chrome.tabs.getCurrent(tab => chrome.tabs.executeScript(tab.id, { code: `document.furnish.__cache__ = () => {${ value }}` }, __cache__ => element[name] = __cache__[0] || parent.furnish.__cache__ || value));
+ } catch (_error) {
+ throw __error, _error;
+ }
+ }
+ })():
+ element[name] = value:
+ element.setAttribute(name, value)
+ );
+
+ children
+ .filter( child => child !== undefined && child !== null )
+ .forEach(
+ child =>
+ child instanceof Element?
+ element.append(child):
+ child instanceof Node?
+ element.appendChild(child):
+ element.appendChild(
+ parent.createTextNode(child)
+ )
+ );
+
+ return element;
+ }
+})(document);
+
+let PRIMITIVE = Symbol.toPrimitive,
+ queryBy = document.queryBy,
+ furnish = document.furnish;
+
+queryBy[PRIMITIVE] = furnish[PRIMITIVE] = String.prototype.toCaps[PRIMITIVE] = () => "function () { [foreign code] }";
+
+if(chrome.runtime.lastError)
+ chrome.runtime.lastError.message;