NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name BLU Streaming Links // @description Show streaming links on movie pages // @version 2.3 // @author MiM // @Credits SB100 // @copyright 2023 // @license MIT // @include https://blutopia.cc/torrents/* // @include https://blutopia.cc/requests/* // @include https://blutopia.cc/mediahub/movies/* // @grant GM_xmlhttpRequest // @connect apis.justwatch.com // @icon https://blutopia.cc/favicon.ico // @updateURL https://openuserjs.org/meta/MiM/BLU_Streaming_Links.meta.js // @downloadURL https://openuserjs.org/install/MiM/BLU_Streaming_Links.user.js // ==/UserScript== // ==OpenUserJS== // @author MiM // ==/OpenUserJS== /* jshint esversion: 6 */ /** * ============================= * ADVANCED OPTIONS * ============================= */ /** * A map of BLU Langauges to JustWatch locales. * If a language doesn't match the locale you are in, please update it to retrieve local prices */ var LANGUAGE_MAP = { 'English AU': 'en_AU', 'English CA': 'en_CA', 'English GB': 'en_GB', 'English NZ': 'en_NZ', 'English PH': 'en_PH', 'English SG': 'en_SG', 'English US': 'en_US', 'English ZA': 'en_ZA', 'Default': 'en_US', } // Save a list of all the possible ones. var href_lang_tags = []; /** * Messages to show when all versions (SD, HD, 4K) or a film are found in a specific category */ const TEXT_ALL_EDITIONS_AVAILABLE = { 'STREAM': 'All editions available', 'RENT': 'All editions available', 'BUY': 'All editions available' } /** * Error messages to show when a version is missing from one of the categories */ const TEXT_NO_LINK_FOUND = { 'STREAM': 'No links found', 'RENT': 'No links found', 'BUY': 'No links found' } /** * Force the streaming panel to always start in a specific state (opened or closed). Otherwise, the toggle state will be remembered, and used on other movie pages. * Allowed enums: 'none', 'open', 'close' */ const SETTING_FORCE_PANEL_DEFAULT_STATE = 'none'; /** * Sets whether the locale selector should be dimmed or not. Will become full opacity on hover */ const SETTING_LANG_SELECT_DIMMED = true; // How long to cache per-locale providers for. 24 hours is the default. const CACHE_TIME = 1000 * 60 * 60 * 24; // We try matching the movie name as well as the release year, but sometimes the year is off. Increasing this number, will allow // search to match films in a wider range of years. // e.g. Avengers Endgame [2019] with a searchYearModifier of 1 will match years 2018, 2019 and 2020. // Set to 0 to be strict on release years const searchYearModifier = 1; /** * ============================= * END ADVANCED OPTIONS * DO NOT MODIFY BELOW THIS LINE * ============================= */ /** * Query the JustWatch API */ function query(path, method, params = {}) { let resolver; let rejecter; const p = new Promise((resolveFn, rejectFn) => { resolver = resolveFn; rejecter = rejectFn; }); const paramStr = new URLSearchParams(params).toString(); const obj = { method, timeout: 10000, onloadstart: () => {}, onload: (response) => resolver(response), onerror: (response) => rejecter(response), ontimeout: (response) => rejecter(response) } const final = method === 'post' ? Object.assign({}, obj, { url: `https://apis.justwatch.com/contentpartner/v2/content${path}`, headers: { "Content-Type": "application/json" }, data: JSON.stringify(params), }) : Object.assign({}, obj, { url: `https://apis.justwatch.com/contentpartner/v2/content${path}${paramStr.length > 0 ? `?${paramStr}` : ''}`, }); GM_xmlhttpRequest(final); return p; } const imdb = document.getElementsByClassName("meta__imdb")[0].getElementsByClassName("meta-id-tag")[0].href.replace("https://www.imdb.com/title/", ""); /** * Find a specific Film from the JustWatch API */ var imdbID = null; var jwType = null; var jwLocaleList = null; async function queryFind(movieTitle, releaseYear, locale) { let result = {} const json = JSON.parse(result.response); // Needs some update for TV too. TV will use class="tags" and valud "TV Show" (needs a trim) /* const filtered = json.items.filter( item => (item.object_type === (isTV ? 'show' : 'movie') && (item.original_release_year - searchYearModifier <= releaseYear && item.original_release_year + searchYearModifier >= releaseYear) || releaseYear == null) || item.id == jwID ); if (filtered.length > 0) { const lowercaseMovieTitle = movieTitle.toLowerCase(); for (let i = 0, len = filtered.length; i < len; i += 1) { const lowercaseFilteredTitle = filtered[i].title.toLowerCase(); if (lowercaseFilteredTitle === lowercaseMovieTitle || filtered[i].id === jwID) { jwID = filtered[i].id; jwType = filtered[i].object_type; const jwFullPath = filtered[i].full_path; if (!jwLocaleList) { const jwLocales = await findLocales(jwFullPath); if (jwLocales.status === 200) { let jwLocalesJson = JSON.parse(jwLocales.response); jwLocaleList = jwLocalesJson.href_lang_tags.sort(function(a, b) { let aName = localeToName(a.locale); let bName = localeToName(b.locale); if (aName < bName) { return -1; } if (aName > bName) { return 1; } return 0; }); updateLanguageMap(jwLocaleList); } } return filtered[i]; } } }*/ return null; } function updateLanguageMap(locales) { // Clear the language map and set accordingly. LANGUAGE_MAP = {} locales.forEach(function (currentValue, index, arr) { let localeName = currentValue.locale; localeName = localeToName(localeName); if (!LANGUAGE_MAP.hasOwnProperty(localeName)) { LANGUAGE_MAP[localeName] = currentValue.locale; } }); } function localeToName(loc) { if (!loc) { // Sanity check for null return loc; } // Language replace, start of string. const languageCodeRegex = /^[a-z]{2}_/; let languageCode = loc.match(languageCodeRegex); if (languageCode) { languageCode = languageCode[0].replace('_', ''); if (languageCode in LANGUAGE_CODES) { loc = loc.replace(`${languageCode}_`, `${LANGUAGE_CODES[languageCode]} _`); } } // Country replace, end of string. const countryCodeRegex = /_[A-Z]{2}$/; let countryCode = loc.match(countryCodeRegex); if (countryCode) { countryCode = countryCode[0].replace('_', ''); if (countryCode in COUNTRY_CODES) { loc = loc.replace(`_${countryCode}`, `- ${COUNTRY_CODES[countryCode]}`); } } return loc; } const COUNTRY_CODES = { //ISO 3166-1 (Alpha-2 code) 'AF': 'Afghanistan', 'AL': 'Albania', 'DZ': 'Algeria', 'AS': 'American Samoa', 'AD': 'Andorra', 'AO': 'Angola', 'AI': 'Anguilla', 'AQ': 'Antarctica', 'AG': 'Antigua and Barbuda', 'AR': 'Argentina', 'AM': 'Armenia', 'AW': 'Aruba', 'AU': 'Australia', 'AT': 'Austria', 'AZ': 'Azerbaijan', 'BS': 'Bahamas', 'BH': 'Bahrain', 'BD': 'Bangladesh', 'BB': 'Barbados', 'BY': 'Belarus', 'BE': 'Belgium', 'BZ': 'Belize', 'BJ': 'Benin', 'BM': 'Bermuda', 'BT': 'Bhutan', 'BO': 'Bolivia', 'BA': 'Bosnia and Herzegovina', 'BW': 'Botswana', 'BR': 'Brazil', 'IO': 'British Indian Ocean Territory', 'VG': 'British Virgin Islands', 'BN': 'Brunei', 'BG': 'Bulgaria', 'BF': 'Burkina Faso', 'BI': 'Burundi', 'KH': 'Cambodia', 'CM': 'Cameroon', 'CA': 'Canada', 'CV': 'Cape Verde', 'KY': 'Cayman Islands', 'CF': 'Central African Republic', 'TD': 'Chad', 'CL': 'Chile', 'CN': 'China', 'CX': 'Christmas Island', 'CC': 'Cocos Islands', 'CO': 'Colombia', 'KM': 'Comoros', 'CK': 'Cook Islands', 'CR': 'Costa Rica', 'HR': 'Croatia', 'CU': 'Cuba', 'CW': 'Curacao', 'CY': 'Cyprus', 'CZ': 'Czech Republic', 'CD': 'Democratic Republic of the Congo', 'DK': 'Denmark', 'DJ': 'Djibouti', 'DM': 'Dominica', 'DO': 'Dominican Republic', 'TL': 'East Timor', 'EC': 'Ecuador', 'EG': 'Egypt', 'SV': 'El Salvador', 'GQ': 'Equatorial Guinea', 'ER': 'Eritrea', 'EE': 'Estonia', 'ET': 'Ethiopia', 'FK': 'Falkland Islands', 'FO': 'Faroe Islands', 'FJ': 'Fiji', 'FI': 'Finland', 'FR': 'France', 'PF': 'French Polynesia', 'GA': 'Gabon', 'GM': 'Gambia', 'GE': 'Georgia', 'DE': 'Germany', 'GH': 'Ghana', 'GI': 'Gibraltar', 'GR': 'Greece', 'GL': 'Greenland', 'GD': 'Grenada', 'GU': 'Guam', 'GT': 'Guatemala', 'GG': 'Guernsey', 'GN': 'Guinea', 'GW': 'Guinea-Bissau', 'GY': 'Guyana', 'HT': 'Haiti', 'HN': 'Honduras', 'HK': 'Hong Kong', 'HU': 'Hungary', 'IS': 'Iceland', 'IN': 'India', 'ID': 'Indonesia', 'IR': 'Iran', 'IQ': 'Iraq', 'IE': 'Ireland', 'IM': 'Isle of Man', 'IL': 'Israel', 'IT': 'Italy', 'CI': 'Ivory Coast', 'JM': 'Jamaica', 'JP': 'Japan', 'JE': 'Jersey', 'JO': 'Jordan', 'KZ': 'Kazakhstan', 'KE': 'Kenya', 'KI': 'Kiribati', 'XK': 'Kosovo', 'KW': 'Kuwait', 'KG': 'Kyrgyzstan', 'LA': 'Laos', 'LV': 'Latvia', 'LB': 'Lebanon', 'LS': 'Lesotho', 'LR': 'Liberia', 'LY': 'Libya', 'LI': 'Liechtenstein', 'LT': 'Lithuania', 'LU': 'Luxembourg', 'MO': 'Macau', 'MK': 'Macedonia', 'MG': 'Madagascar', 'MW': 'Malawi', 'MY': 'Malaysia', 'MV': 'Maldives', 'ML': 'Mali', 'MT': 'Malta', 'MH': 'Marshall Islands', 'MR': 'Mauritania', 'MU': 'Mauritius', 'YT': 'Mayotte', 'MX': 'Mexico', 'FM': 'Micronesia', 'MD': 'Moldova', 'MC': 'Monaco', 'MN': 'Mongolia', 'ME': 'Montenegro', 'MS': 'Montserrat', 'MA': 'Morocco', 'MZ': 'Mozambique', 'MM': 'Myanmar', 'NA': 'Namibia', 'NR': 'Nauru', 'NP': 'Nepal', 'NL': 'Netherlands', 'AN': 'Netherlands Antilles', 'NC': 'New Caledonia', 'NZ': 'New Zealand', 'NI': 'Nicaragua', 'NE': 'Niger', 'NG': 'Nigeria', 'NU': 'Niue', 'NP': 'North Korea', 'MP': 'Northern Mariana Islands', 'NO': 'Norway', 'OM': 'Oman', 'PK': 'Pakistan', 'PW': 'Palau', 'PS': 'Palestine', 'PA': 'Panama', 'PG': 'Papua New Guinea', 'PY': 'Paraguay', 'PE': 'Peru', 'PH': 'Philippines', 'PN': 'Pitcairn', 'PL': 'Poland', 'PT': 'Portugal', 'PR': 'Puerto Rico', 'QA': 'Qatar', 'CG': 'Republic of the Congo', 'RE': 'Reunion', 'RO': 'Romania', 'RU': 'Russia', 'RW': 'Rwanda', 'BL': 'Saint Barthelemy', 'SH': 'Saint Helena', 'KN': 'Saint Kitts and Nevis', 'LC': 'Saint Lucia', 'MF': 'Saint Martin', 'PM': 'Saint Pierre and Miquelon', 'VC': 'Saint Vincent and the Grenadines', 'WS': 'Samoa', 'SM': 'San Marino', 'ST': 'Sao Tome and Principe', 'SA': 'Saudi Arabia', 'SN': 'Senegal', 'RS': 'Serbia', 'SC': 'Seychelles', 'SL': 'Sierra Leone', 'SG': 'Singapore', 'SX': 'Sint Maarten', 'SK': 'Slovakia', 'SI': 'Slovenia', 'SB': 'Solomon Islands', 'SO': 'Somalia', 'ZA': 'South Africa', 'KR': 'South Korea', 'SS': 'South Sudan', 'ES': 'Spain', 'LK': 'Sri Lanka', 'SD': 'Sudan', 'SR': 'Suriname', 'SJ': 'Svalbard and Jan Mayen', 'SZ': 'Swaziland', 'SE': 'Sweden', 'CH': 'Switzerland', 'SY': 'Syria', 'TW': 'Taiwan', 'TJ': 'Tajikistan', 'TZ': 'Tanzania', 'TH': 'Thailand', 'TG': 'Togo', 'TK': 'Tokelau', 'TO': 'Tonga', 'TT': 'Trinidad and Tobago', 'TN': 'Tunisia', 'TR': 'Turkey', 'TM': 'Turkmenistan', 'TC': 'Turks and Caicos Islands', 'TV': 'Tuvalu', 'VI': 'U.S. Virgin Islands', 'UG': 'Uganda', 'UA': 'Ukraine', 'AE': 'United Arab Emirates', 'GB': 'United Kingdom', 'US': 'United States', 'UY': 'Uruguay', 'UZ': 'Uzbekistan', 'VU': 'Vanuatu', 'VA': 'Vatican', 'VE': 'Venezuela', 'VN': 'Vietnam', 'WF': 'Wallis and Futuna', 'EH': 'Western Sahara', 'YE': 'Yemen', 'ZM': 'Zambia', 'ZW': 'Zimbabwe' }; const LANGUAGE_CODES = { //639-1 'ab': 'Abkhazian', 'aa': 'Afar', 'af': 'Afrikaans', 'ak': 'Akan', 'sq': 'Albanian', 'am': 'Amharic', 'ar': 'Arabic', 'an': 'Aragonese', 'hy': 'Armenian', 'as': 'Assamese', 'av': 'Avaric', 'ae': 'Avestan', 'ay': 'Aymara', 'az': 'Azerbaijani', 'bm': 'Bambara', 'ba': 'Bashkir', 'eu': 'Basque', 'be': 'Belarusian', 'bn': 'Bengali', 'bi': 'Bislama', 'bs': 'Bosnian', 'br': 'Breton', 'bg': 'Bulgarian', 'my': 'Burmese', 'ca': 'Catalan', 'ch': 'Chamorro', 'ce': 'Chechen', 'ny': 'Chichewa, Chewa, Nyanja', 'zh': 'Chinese', 'cv': 'Chuvash', 'kw': 'Cornish', 'co': 'Corsican', 'cr': 'Cree', 'hr': 'Croatian', 'cs': 'Czech', 'da': 'Danish', 'dv': 'Divehi', 'nl': 'Dutch, Flemish', 'dz': 'Dzongkha', 'en': 'English', 'eo': 'Esperanto', 'et': 'Estonian', 'ee': 'Ewe', 'fo': 'Faroese', 'fj': 'Fijian', 'fi': 'Finnish', 'fr': 'French', 'ff': 'Fulah', 'gl': 'Galician', 'ka': 'Georgian', 'de': 'German', 'el': 'Greek', 'gn': 'Guarani', 'gu': 'Gujarati', 'ht': 'Haitian', 'ha': 'Hausa', 'he': 'Hebrew', 'hz': 'Herero', 'hi': 'Hindi', 'ho': 'Hiri Motu', 'hu': 'Hungarian', 'ia': 'Interlingua', 'id': 'Indonesian', 'ie': 'Interlingue', 'ga': 'Irish', 'ig': 'Igbo', 'ik': 'Inupiaq', 'io': 'Ido', 'is': 'Icelandic', 'it': 'Italian', 'iu': 'Inuktitut', 'ja': 'Japanese', 'jv': 'Javanese', 'kl': 'Kalaallisut', 'kn': 'Kannada', 'kr': 'Kanuri', 'ks': 'Kashmiri', 'kk': 'Kazakh', 'km': 'Central Khmer', 'ki': 'Kikuyu, Gikuyu', 'rw': 'Kinyarwanda', 'ky': 'Kirghiz, Kyrgyz', 'kv': 'Komi', 'kg': 'Kongo', 'ko': 'Korean', 'ku': 'Kurdish', 'kj': 'Kuanyama', 'la': 'Latin', 'lb': 'Luxembourgish', 'lg': 'Ganda', 'li': 'Limburgan', 'ln': 'Lingala', 'lo': 'Lao', 'lt': 'Lithuanian', 'lu': 'Luba-Katanga', 'lv': 'Latvian', 'gv': 'Manx', 'mk': 'Macedonian', 'mg': 'Malagasy', 'ms': 'Malay', 'ml': 'Malayalam', 'mt': 'Maltese', 'mi': 'Maori', 'mr': 'Marathi', 'mh': 'Marshallese', 'mn': 'Mongolian', 'na': 'Nauru', 'nv': 'Navajo', 'nd': 'North Ndebele', 'ne': 'Nepali', 'ng': 'Ndonga', 'nb': 'Norwegian Bokmål', 'nn': 'Norwegian Nynorsk', 'no': 'Norwegian', 'ii': 'Sichuan Yi, Nuosu', 'nr': 'South Ndebele', 'oc': 'Occitan', 'oj': 'Ojibwa', 'cu': 'Church Slavic', 'om': 'Oromo', 'or': 'Oriya', 'os': 'Ossetian, Ossetic', 'pa': 'Punjabi, Panjabi', 'pi': 'Pali', 'fa': 'Persian', 'pl': 'Polish', 'ps': 'Pashto, Pushto', 'pt': 'Portuguese', 'qu': 'Quechua', 'rm': 'Romansh', 'rn': 'Rundi', 'ro': 'Romanian', 'ru': 'Russian', 'sa': 'Sanskrit', 'sc': 'Sardinian', 'sd': 'Sindhi', 'se': 'Northern Sami', 'sm': 'Samoan', 'sg': 'Sango', 'sr': 'Serbian', 'gd': 'Gaelic', 'sn': 'Shona', 'si': 'Sinhala', 'sk': 'Slovak', 'sl': 'Slovenian', 'so': 'Somali', 'st': 'Southern Sotho', 'es': 'Spanish', 'su': 'Sundanese', 'sw': 'Swahili', 'ss': 'Swati', 'sv': 'Swedish', 'ta': 'Tamil', 'te': 'Telugu', 'tg': 'Tajik', 'th': 'Thai', 'ti': 'Tigrinya', 'bo': 'Tibetan', 'tk': 'Turkmen', 'tl': 'Tagalog', 'tn': 'Tswana', 'to': 'Tonga', 'tr': 'Turkish', 'ts': 'Tsonga', 'tt': 'Tatar', 'tw': 'Twi', 'ty': 'Tahitian', 'ug': 'Uighur', 'uk': 'Ukrainian', 'ur': 'Urdu', 'uz': 'Uzbek', 've': 'Venda', 'vi': 'Vietnamese', 'vo': 'Volapük', 'wa': 'Walloon', 'cy': 'Welsh', 'wo': 'Wolof', 'fy': 'Western Frisian', 'xh': 'Xhosa', 'yi': 'Yiddish', 'yo': 'Yoruba', 'za': 'Zhuang, Chuang', 'zu': 'Zulu' }; /** * Find all the locales that have it. * Input is the uri (locale/type/name) */ function findLocales(uri) { let params = new URLSearchParams(""); params.set('include_children', 'true'); params.set('path', uri); params.set('token', "ABCdef12"); let url = `https://apis.justwatch.com/content/urls?${params.toString()}?token=ABCdef12`; //`https://apis.justwatch.com/contentpartner/v2/content/countries?language=en&token=ABCdef12` const headers = { "Content-Type": "application/json" }; let resolver; let rejecter; const p = new Promise((resolveFn, rejectFn) => { resolver = resolveFn; rejecter = rejectFn; }); const final = GM_xmlhttpRequest({ method: "GET", url: url, headers: headers, onload: (response) => resolver(response), onerror: (response) => rejecter(response), ontimeout: (response) => rejecter(response) }); return p; } let media_type = "movie"; function findByIMDb(id, locale) { let url = `https://apis.justwatch.com/contentpartner/v2/content/offers/object_type/${media_type}/id_type/imdb/id/${imdb}/locale/${locale}?language=en&token=ABCdef12`; console.log(url) const headers = { "Content-Type": "application/json" }; let resolver; let rejecter; const p = new Promise((resolveFn, rejectFn) => { resolver = resolveFn; rejecter = rejectFn; }); const final = GM_xmlhttpRequest({ method: "GET", url: url, headers: headers, onload: (response) => resolver(response), onerror: (response) => rejecter(response), ontimeout: (response) => rejecter(response) }); return p; } /** * Find all Providers from the JustWatch API */ async function queryProviders(locale) { const result = await query(`/providers/all/locale/${locale}`, 'get', { token: 'ABCdef12' }); if (result.status !== 200) { return null; } const json = JSON.parse(result.response); if (!json) { return null; } return json.reduce((result, provider) => { result[provider.id] = { name: provider.clear_name, icon: provider.icon_url }; return result; }, {}); } /** * Try parsing a string into JSON, otherwise fallback */ function JsonParseWithDefault(s, fallback = null) { try { return JSON.parse(s); } catch (e) { return fallback; } } /** * Get all settings stored in localStorage for this script */ function getSettings() { const settings = window.localStorage.getItem('streamingLinkSettings'); return JsonParseWithDefault(settings || {}, {}); } /** * Set a setting into localStorage for this script */ function setSetting(name, value) { const json = getSettings(); json[name] = value; window.localStorage.setItem('streamingLinkSettings', JSON.stringify(json)); } /** * Cache / Retrieve the list of Providers from the JustWatch API * Cache time is controlled by the CACHE_TIME variable at the top of the script */ async function getProviders(locale) { const sortedKeys = Object .keys(window.localStorage) .filter(key => key.startsWith(`justWatchProviders-${locale}-`)) .sort((a, b) => { const aTime = parseInt(a.replace(`justWatchProviders-${locale}-`, ''), 10); const bTime = parseInt(b.replace(`justWatchProviders-${locale}-`, ''), 10); return aTime - bTime; }); for (let i = 0, sortedKeysLength = sortedKeys.length; i < sortedKeysLength; i += 1) { const key = sortedKeys[i]; const keyTime = parseInt(key.replace(`justWatchProviders-${locale}-`, ''), 10); // last entry, and we're still in the cache period if (i === sortedKeysLength - 1 && (new Date).getTime() - keyTime < CACHE_TIME) { return JsonParseWithDefault(window.localStorage.getItem(key)); } window.localStorage.removeItem(key); } // if we didn't find an entry, query the api and store in localStorage const providers = await queryProviders(locale); if (providers && Object.keys(providers).length > 0) { window.localStorage.setItem(`justWatchProviders-${locale}-${(new Date).getTime()}`, JSON.stringify(providers)); return providers; } // nothing found =( return null; } /** * Create a full icon url from JustWatch */ function makeIconUrl(providerId, providers) { const iconUrl = providers[providerId] && providers[providerId].icon; return iconUrl.replace('{profile}', 's100') } /** * Format a number with a symbol, and approproate locale separators */ function formatCurrency(num, currency = 'USD', locale = 'en-US') { return new Intl.NumberFormat(locale.replace('_', '-'), { style: 'currency', currency }).format(num); } /** * Parse the Movie name and year from the movie page */ function getMovieNameAndYear() { let metaInfo = document.getElementsByClassName("meta")[0]; let movieHeading = metaInfo.getElementsByClassName("meta__title-link")[0]; let movieInfo = movieHeading.getElementsByTagName("h1"); let movieName = null; let year = null; let last_paran = -1; if (movieInfo.length > 0) { movieName = movieInfo[0].innerHTML; movieName = movieName.replace('&', '&'); movieName = movieName.replace('<', '<').replace('>', '>'); last_paran = movieName.lastIndexOf("("); year = movieName.slice(last_paran + 1, movieName.lastIndexOf(")")) if (year == '') { year = null; } movieName = movieName.slice(0, last_paran) movieName = movieName.trim(); } return { movieName, year } } /** * Turn a monetization type into a short string to show on BLU */ function monetizationToShort(monetization) { switch (monetization.toLowerCase()) { case 'flatrate': return 'Sub'; case 'free': return 'Free'; case 'ads': return 'Ads'; default: '???'; } } /** * Get the default open state of the streaming panel */ function getInitialOpenState() { if (SETTING_FORCE_PANEL_DEFAULT_STATE === 'open') return true; if (SETTING_FORCE_PANEL_DEFAULT_STATE === 'close') return false; const settings = getSettings(); return settings.toggleOpen ? true : false; } /** * Find the appropriate locale for a langauge listed in the Movie Info panel. */ function getLangaugeLocale() { const movieInfo = document.querySelector('#movieinfo'); const sections = movieInfo && movieInfo.querySelectorAll('.panel__body > div'); const langs = sections && Array.from(sections).filter(div => div.innerText.startsWith('Language')); if (!langs || langs.length === 0) { return "en_US"; } const langArray = langs[0].innerText.replace('Language: ', '').split(', '); for (let i = 0, len = langArray.length; i < len; i += 1) { const lang = langArray[i]; if (Object.prototype.hasOwnProperty.call(LANGUAGE_MAP, lang)) { return LANGUAGE_MAP[lang]; } } return LANGUAGE_MAP.Default; } /** * Decides which side message to show depending on the messageSlug passed in */ function createSideMessage(name, messageSlug, missing = []) { switch (messageSlug) { case 'LOADING': return `<div class="streaming__missing streaming__missing--grey">Loading ...</div>`; case 'NO_MOVIE_YEAR': return `<div class="streaming__missing streaming__missing--red">Could not parse movie name and/or year</div>`; case 'NO_PROVIDER': return `<div class="streaming__missing streaming__missing--red">Could not find JustWatch providers</div>`; case 'NO_ITEMS': return `<div class="streaming__missing streaming__missing--gold">${TEXT_NO_LINK_FOUND[name]}</div>`; case 'MISSING': return `<div class="streaming__missing streaming__missing--red">Missing: ${missing.join(', ')}</div>`; case 'ALL_AVAILABLE': return `<div class="streaming__missing streaming__missing--green">${TEXT_ALL_EDITIONS_AVAILABLE[name]}</div>`; default: return `<div class="streaming__missing streaming__missing--red">Unknown Status</div>`; } } /** * Continuously check the requests page to see if the user has filled in a name and year for the movie */ function checkChange() { let lastTitle; let lastYear; const inputTitle = document.querySelector('#title'); const inputYear = document.querySelector('#year'); const checkRecreate = () => { const title = inputTitle.value; const year = inputYear.value; const yearInt = parseInt(year, 10); if (title === lastTitle && year === lastYear) { return; } lastTitle = title; lastYear = year; if (title.length > 0 && year.length === 4 && !Number.isNaN(yearInt)) { const selector = document.querySelector('#streaming__select'); createPanelBody(selector.value, title, yearInt); } }; if (!inputTitle || !inputYear) { return } setInterval(() => { checkRecreate(); }, 2000); } function DeleteKeys(myObj, array) { for (let index = 0; index < array.length; index++) { delete myObj[array[index]]; } return myObj; } /** * Create a row in the new streamable panel */ function createRow(name, messageSlug, locale, items, providers) { let missing = ['SD', 'HD', '4K']; const byProvider = items.reduce((results, item) => { if (!results[item.provider_id]) results[item.provider_id] = []; results[item.provider_id].push(item); return results; }, {}); const built = Object .keys(byProvider) // some providers are missing from the provider list. We can't show them, and JustWatch doesn't either, so let's remove them .filter(providerId => typeof providers[providerId] !== 'undefined') // sort alphabetically .sort((a, b) => { const aProvider = providers[a].name; const bProvider = providers[b].name; if (aProvider > bProvider) return 1; if (bProvider > aProvider) return -1; return 0; }) .map(providerId => { const item = byProvider[providerId]; const types = item.map(i => `<li>${i.retail_price ? formatCurrency(i.retail_price, i.currency, locale) : monetizationToShort(item[0].monetization_type)} <span style="color: #C8AF4B;">${i.presentation_type.toUpperCase()}</span></li>`).join(''); // remove types that are present from the 'missing' array item.forEach(i => { missing = missing.filter(m => m !== i.presentation_type.toUpperCase()); }); return `<a class="streaming__item" href="${item[0].urls.standard_web}" target="_blank" rel="noopener noreferrer"> <img class="streaming__img" src="${makeIconUrl(providerId, providers)}" title="${providers[providerId] && providers[providerId].name || 'Unknown'}" /> <ul class="list list--unstyled" style="list-style-type: none; padding: 8px; margin: 0; text-align: right">${types}</ul> </a>`; }); let slug = messageSlug; if (!slug) { slug = (items.length === 0 ? 'NO_ITEMS' : (missing.length > 0 ? 'MISSING' : 'ALL_AVAILABLE')); } /* switch (slug) { case 'NO_MOVIE_YEAR': case 'NO_PROVIDER': case 'NO_ITEMS': LANGUAGE_MAP = Object.keys(LANGUAGE_MAP).reduce(function (filtered, key) { if (LANGUAGE_MAP[key] != locale) filtered[key] = LANGUAGE_MAP[key]; return filtered; }, {}); break; default: break; } */ return `<div class="streaming__name"> ${createSideMessage(name, slug, missing)} ${name} </div> <div class="streaming__row">${built.join('')}</div>`; } /** * Create the footer with the select drop down, and a note on which editions are available on BLU */ function createFooter(locale, justWatchObj, movieName, year) { const selector = document.createElement('select'); selector.id = 'streaming__select'; selector.setAttribute("class", "vscomp-toggle-button"); selector.onchange = (event) => { createPanelBody(event.target.value, movieName, year) } Object.keys(LANGUAGE_MAP).forEach(lang => { const option = document.createElement('option'); option.value = LANGUAGE_MAP[lang]; option.innerText = `${lang} (${LANGUAGE_MAP[lang]})`; option.selected = LANGUAGE_MAP[lang] === locale; selector.appendChild(option); }); const selectorDiv = document.createElement('div'); selectorDiv.classList.add('streaming__footer-locale', SETTING_LANG_SELECT_DIMMED ? 'streaming__footer-locale--dimmed' : null); selectorDiv.appendChild(document.createTextNode('Locale: ')); selectorDiv.appendChild(selector); const infoDiv = document.createElement('div'); infoDiv.classList.add('streaming__footer-info'); if (movieName && year) { const searchInfo = `BLU: ${movieName} [${year}] JustWatch: ${justWatchObj.title && justWatchObj.original_release_year ? `${justWatchObj.title} [${justWatchObj.original_release_year}]` : 'Not found'}`; infoDiv.innerHTML = `<span title="${searchInfo}">search info</span> | <a target="_blank" rel="noopener noreferrer" href="https://justwatch.com${justWatchObj.full_path ? justWatchObj.full_path.replace(/(\/[^/]+)(.*)/, "$1") : '/us'}/search?q=${encodeURIComponent(movieName + ' ' + year)}"> search link </a>`; } const footer = document.createElement('div'); footer.classList.add('streaming__footer'); footer.prepend(infoDiv); footer.prepend(selectorDiv); return footer; } /** * Create the rows and footer in the panel body */ function createRows(panelBody, messageSlug, locale, justWatchObj, providers, movieName, year) { const rows = `${createRow('STREAM', messageSlug, locale, justWatchObj.offers.filter(offer => ['flatrate', 'ads', 'free'].includes(offer.monetization_type)), providers)} ${createRow('RENT', messageSlug, locale, justWatchObj.offers.filter(offer => offer.monetization_type === 'rent'), providers)} ${createRow('BUY', messageSlug, locale, justWatchObj.offers.filter(offer => offer.monetization_type === 'buy'), providers)}`; panelBody.innerHTML = rows; panelBody.appendChild(createFooter(locale, justWatchObj, movieName, year)); } /** * Query JustWatch and create the rows */ async function createPanelBody(forcedLocale = null, movieName = null, year = null) { const defaultJustWatchObj = { offers: [] }; let justWatchObj; let locale = LANGUAGE_MAP.Default; const panel = document.querySelector('#panel__streaming'); if (!panel) { return; } if (panel.dataset.hasRun === '1' && forcedLocale === null) { return; } const body = panel.querySelector('.panel__body'); createRows(body, 'LOADING', forcedLocale || locale, defaultJustWatchObj, null, null, null); if (!movieName || !year) { const { movieName: parsedMovieName, year: parsedYear } = getMovieNameAndYear(); if (!parsedMovieName) { //|| !parsedYear) { createRows(body, 'NO_MOVIE_YEAR', forcedLocale || locale, defaultJustWatchObj, null, null, null); return; } movieName = parsedMovieName; year = parsedYear; } let isTV = false; try { isTV = document.getElementsByClassName("torrent__category-link")[0].innerHTML.trim() == "TV Show"; } catch { try { isTV = document.getElementsByClassName("request__category")[0].getElementsByTagName("span")[0].innerHTML.trim() == "TV Show"; } catch { isTV = document.getElementsByClassName("meta-id-tag")[0].href.includes("tv"); } }; try { isTV = document.getElementsByClassName("torrent-search--list__category")[0].getElementsByTagName("img")[0].alt == "TV Show"; } catch {} if (isTV) { media_type = "show"; } locale = "en_US" if (forcedLocale === null) { const parsedLocale = getLangaugeLocale(); justWatchObj = await findByIMDb(imdb, locale); if ((!justWatchObj || !justWatchObj.offers) && parsedLocale !== locale) { locale = parsedLocale; justWatchObj = await findByIMDb(imdb, locale); } } else { justWatchObj = await findByIMDb(imdb, forcedLocale); } if (justWatchObj) { justWatchObj = JSON.parse(justWatchObj.responseText) } // assign a default if we couldn't query the api if (!justWatchObj || !justWatchObj.offers || justWatchObj.offers.length === 0) { justWatchObj = defaultJustWatchObj; } const providers = await getProviders(forcedLocale || locale); if (!providers) { createRows(body, 'NO_PROVIDER', forcedLocale || locale, defaultJustWatchObj, null, null, null); return; } const jwLocales = await findLocales(justWatchObj.full_path); if (jwLocales.status === 200) { let jwLocalesJson = JSON.parse(jwLocales.response); jwLocaleList = jwLocalesJson.href_lang_tags.sort(function (a, b) { let aName = localeToName(a.locale); let bName = localeToName(b.locale); if (aName < bName) { return -1; } if (aName > bName) { return 1; } return 0; }); updateLanguageMap(jwLocaleList); } // build rows and mark as run createRows(body, null /* auto selected in the function*/ , forcedLocale || locale, justWatchObj, providers, movieName, year); panel.dataset.hasRun = 1; } /** * Create the streamable panel container */ function createPanel() { const isOpen = getInitialOpenState(); const requestsPage = document.URL.indexOf("/requests/") >= 0; const similarPage = document.URL.indexOf("/similar/") >= 0; const mediahubPage = document.URL.indexOf("mediahub/") >= 0; const blockSingle = requestsPage || similarPage || mediahubPage; const panel = document.createElement('section'); panel.id = 'panel__streaming'; panel.classList.add('panel'); //${blockSingle ? '<div class="block single">' : ''} panel.innerHTML = ` <div class="panelV2"> <header class="panel__header"> <h2 class="panel__heading"> <i class="fas fa-cloud"></i> Streaming <a id="panel__toggle" class="panel__heading__toggler" style="font-size: 0.9em;" title="Toggle" href="#streaming">(${isOpen ? 'Hide' : 'Show'} links)</a> </h2> </header> <div class="panel__body bbcode-rendered"> <div class="panel__body ${isOpen ? '' : 'hidden'}"> </div> </div> </div>`; //${blockSingle ? '</div>' : ''} if (window.location.href.indexOf("requests") > -1) { let firstBlock = document.getElementsByClassName("panelV2")[1]; firstBlock.after(panel); } else if (window.location.href.indexOf("similar") > -1) { //let firstBlock = document.getElementsByClassName("block single")[0]; //firstBlock.after(panel); let num_panels = document.getElementsByClassName("panelV2").length - 1 let firstBlock = document.getElementsByClassName("panelV2")[num_panels]; firstBlock.after(panel); } else { let num_panels = document.getElementsByClassName("panelV2").length - 3 let firstBlock = document.getElementsByClassName("panelV2")[num_panels]; firstBlock.after(panel); } if (isOpen) { createPanelBody(); } const panelToggle = document.querySelector('#panel__toggle'); panelToggle.addEventListener('click', () => { const panelBody = document.querySelector('#panel__streaming .panel__body'); if (panelBody.classList.contains('hidden')) { panelBody.classList.remove('hidden'); panelToggle.innerText = '(Hide links)'; setSetting('toggleOpen', true); createPanelBody(); } else { panelBody.classList.add('hidden'); panelToggle.innerText = '(Show links)'; setSetting('toggleOpen', false); } }); } /** * All the custom styling that powers the loadout. BEM FTW. */ function createStyleTag() { const css = `.streaming__row { display: flex; flex-direction: row; margin-bottom: 10px; border-bottom: 1px solid #666; padding-bottom: 10px; flex-wrap: wrap; } .streaming__name { font-weight: bold; text-transform: uppercase; } .streaming__footer { display: flex; justify-content: space-between; } .streaming__footer-locale { display: inline-block; vertical-align: middle; } .streaming__footer-locale--dimmed { opacity: 0.5; } .streaming__footer-locale:hover { opacity: 1; } .streaming__footer-info { color: #999; cursor: help; line-height: 18px; } .streaming__footer-info a { color: #999; } .streaming__missing { font-weight: bold; float: right; text-align: right; text-transform: none; font-size: 0.9em; } .streaming__missing-footer { line-height: 18px; } .streaming__missing--red { color: #ff0000; } .streaming__missing--green { color: #009000; } .streaming__missing--gold { color: #C8AF4B; } .streaming__missing--grey { color: #CCC; } .streaming__item { display: block; margin-right: 15px; margin-top: 10px; text-align: center; } .streaming__img { width: 50px; border-radius: 10px; }`; const style = document.createElement('style'); style.type = 'text/css'; style.appendChild(document.createTextNode(css)); document.head.appendChild(style); } // Main script runner (async function () { 'use strict'; createStyleTag(); createPanel(); checkChange(); })();