├── extension ├── settings │ ├── api.json │ └── default.json ├── scripts │ ├── prepareLetterboxdForFading.js │ └── getFilmsFromLetterboxd.js ├── justwatch │ └── logo.png ├── icons │ ├── logo_final.png │ ├── logo_final_128.png │ ├── logo_final_32.png │ └── logo_final_48.png ├── manifest.json ├── LICENSE ├── popup │ ├── popup.html │ ├── popup.css │ └── popup.js ├── style │ └── hideunstreamed.css └── background.js ├── .gitignore ├── tools └── build.sh ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── FUNDING.yml ├── package.json ├── LICENSE └── README.md /extension/settings/api.json: -------------------------------------------------------------------------------- 1 | { 2 | "tmdb": "" 3 | } 4 | -------------------------------------------------------------------------------- /extension/scripts/prepareLetterboxdForFading.js: -------------------------------------------------------------------------------- 1 | document.body.className += ' hide-films-unstreamed'; 2 | -------------------------------------------------------------------------------- /extension/settings/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "country_code": "US", 3 | "provider_id": 8, 4 | "filter_status": false 5 | } 6 | -------------------------------------------------------------------------------- /extension/justwatch/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adlerzei/letterboxd-streaming-providers/HEAD/extension/justwatch/logo.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /builds/* 2 | *.zip 3 | /.idea/ 4 | /.idea/* 5 | /node_modules/ 6 | /node_modules/* 7 | /screenshots/* 8 | /screenshots/ 9 | -------------------------------------------------------------------------------- /extension/icons/logo_final.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adlerzei/letterboxd-streaming-providers/HEAD/extension/icons/logo_final.png -------------------------------------------------------------------------------- /extension/icons/logo_final_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adlerzei/letterboxd-streaming-providers/HEAD/extension/icons/logo_final_128.png -------------------------------------------------------------------------------- /extension/icons/logo_final_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adlerzei/letterboxd-streaming-providers/HEAD/extension/icons/logo_final_32.png -------------------------------------------------------------------------------- /extension/icons/logo_final_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adlerzei/letterboxd-streaming-providers/HEAD/extension/icons/logo_final_48.png -------------------------------------------------------------------------------- /tools/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | echo "Building Extension" 3 | EXTENSIONNAME="Letterboxd-Streaming-Providers" 4 | DES=builds 5 | 6 | TAG=$(date +"%y%m%d"_%H%M) 7 | 8 | FIREFOXFILENAME=${EXTENSIONNAME}_Firefox_Dev_${TAG} 9 | CHROMEFILENAME=${EXTENSIONNAME}_Chrome_Opera_Dev_${TAG} 10 | 11 | mkdir -p $DES 12 | cd extension/ 13 | 14 | zip -r ${FIREFOXFILENAME}.xpi * 15 | mv ${FIREFOXFILENAME}.xpi ../$DES/ 16 | 17 | zip -r ${CHROMEFILENAME}.zip * 18 | mv ${CHROMEFILENAME}.zip ../$DES/ 19 | 20 | echo "Package done." 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Extension version: 11 | Browser: 12 | System: 13 | 14 | **Describe the bug** 15 | A clear and concise description of what the bug is. 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Links to pages that does not work** 21 | Providing links to lists where the extension is failing helps reproducing the issue. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "letterboxd-streaming-providers", 3 | "version": "1.0.0", 4 | "description": "Browser extension that adds a filter for some streaming providers to letterboxd.com", 5 | "main": "background.js", 6 | "dependencies": { 7 | "@types/chrome": "0.0.127", 8 | "npm": "^10.5.2" 9 | }, 10 | "scripts": { 11 | "build": "./tools/build.sh" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/adlerzei/letterboxd-streaming-providers" 16 | }, 17 | "author": "Christian Zei", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/adlerzei/letterboxd-streaming-providers/issues" 21 | }, 22 | "homepage": "https://github.com/adlerzei/letterboxd-streaming-providers#readme" 23 | } 24 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: ['https://www.paypal.com/paypalme/ChristianZei/5'] 16 | -------------------------------------------------------------------------------- /extension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Adds a filter for streaming providers to Letterboxd.", 3 | "author": "Christian Zei", 4 | "manifest_version": 3, 5 | "name": "Letterboxd Streaming Providers", 6 | "short_name": "LSP", 7 | "homepage_url": "https://github.com/adlerzei/letterboxd-streaming-providers#readme", 8 | "version": "1.7.0", 9 | "icons": { 10 | "48": "icons/logo_final_48.png", 11 | "128": "icons/logo_final_128.png" 12 | }, 13 | "permissions": [ 14 | "tabs", 15 | "storage", 16 | "scripting", 17 | "alarms" 18 | ], 19 | "host_permissions": [ 20 | "*://api.themoviedb.org/*", 21 | "*://letterboxd.com/*", 22 | "*://www.letterboxd.com/*" 23 | ], 24 | "background": { 25 | "service_worker": "background.js" 26 | }, 27 | "action": { 28 | "default_icon": "icons/logo_final_32.png", 29 | "default_title": "Letterboxd Streaming Providers", 30 | "default_popup": "popup/popup.html" 31 | } 32 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Christian Zei 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /extension/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Christian Zei 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /extension/popup/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 | Letterboxd Streaming Providers 14 |
15 |
16 | Letterboxd Streaming Providers 17 |
18 |
19 | 20 |
21 | 26 | 27 |
28 |
Country
29 | 30 |
Streaming Provider
31 | 32 |
33 |
34 | 35 | 41 | 42 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /extension/scripts/getFilmsFromLetterboxd.js: -------------------------------------------------------------------------------- 1 | if (typeof browser === 'undefined') { 2 | var browser = chrome; 3 | } 4 | 5 | var filmposters = document.body.getElementsByClassName('poster-container'); 6 | 7 | var movies = {}; 8 | for (let poster = 0; poster < filmposters['length']; poster++) { 9 | let outerDiv = filmposters[poster].children[0]; 10 | if (outerDiv.attributes.hasOwnProperty('data-film-name')) { 11 | let filmName = outerDiv.attributes['data-film-name'].value; 12 | 13 | let filmYear = -1; 14 | if (outerDiv.attributes.hasOwnProperty('data-film-release-year')) { 15 | filmYear = outerDiv.attributes['data-film-release-year'].value; 16 | } 17 | else if (outerDiv.children[0].children[1].attributes.hasOwnProperty('data-original-title')) { 18 | // sometimes, the release year is not specified as own attribute 19 | // we need to find the release year on a different way then 20 | 21 | let movieWithYear = outerDiv.children[0].children[1].attributes['data-original-title'].value; // contains "title (year)" 22 | let yearRegex = /\((\d{4})\)/; 23 | let match = movieWithYear.match(yearRegex); 24 | if (match) { 25 | filmYear = match[1]; // the year is in the first capture group 26 | } 27 | } 28 | else { 29 | // we are unlucky, no date specified 30 | } 31 | 32 | if (movies.hasOwnProperty(filmName)) { 33 | if (movies[filmName].year === -1) { 34 | movies[filmName].year = filmYear; 35 | } 36 | 37 | movies[filmName].id.push(poster); 38 | } else { 39 | movies[filmName] = { 40 | year: filmYear, 41 | id: [poster] 42 | }; 43 | } 44 | } else { 45 | // if poster does not have attribute "data-film-name" it is lazy loaded 46 | // we need to find the poster on a different way then 47 | let filmName = outerDiv.children[0].alt; 48 | 49 | if (movies.hasOwnProperty(filmName)) { 50 | movies[filmName].id.push(poster); 51 | } else { 52 | movies[filmName] = { 53 | year: -1, 54 | id: [poster] 55 | }; 56 | } 57 | } 58 | } 59 | 60 | browser.runtime.sendMessage({ 61 | messageType: 'movie-titles', 62 | messageContent: movies 63 | }); 64 | -------------------------------------------------------------------------------- /extension/style/hideunstreamed.css: -------------------------------------------------------------------------------- 1 | .hide-films-unstreamed .film-not-streamed, 2 | body.my-watchlist .removed-from-watchlist { 3 | opacity: .2; 4 | transition: all .1s linear 5 | } 6 | 7 | .hide-films-unstreamed .film-not-streamed:active, 8 | .hide-films-unstreamed .film-not-streamed:hover, 9 | body.my-watchlist .removed-from-watchlist:active, 10 | body.my-watchlist .removed-from-watchlist:hover { 11 | opacity: 1 12 | } 13 | 14 | .hide-films-unstreamed li.film-not-streamed { 15 | opacity: 1; 16 | transition: all .1s linear 17 | } 18 | 19 | .hide-films-unstreamed li.film-not-streamed>* { 20 | opacity: .2; 21 | transition: all .1s linear 22 | } 23 | 24 | .hide-films-unstreamed li.film-not-streamed:active>*, 25 | .hide-films-unstreamed li.film-not-streamed:hover>* { 26 | opacity: 1 27 | } 28 | 29 | .hide-films-unstreamed #diary-table tr.film-not-streamed { 30 | opacity: 1; 31 | transition: all .1s linear 32 | } 33 | 34 | .hide-films-unstreamed #diary-table tr.film-not-streamed .td-actions>*, 35 | .hide-films-unstreamed #diary-table tr.film-not-streamed .td-film-details>*, 36 | .hide-films-unstreamed #diary-table tr.film-not-streamed .td-like>*, 37 | .hide-films-unstreamed #diary-table tr.film-not-streamed .td-rating>*, 38 | .hide-films-unstreamed #diary-table tr.film-not-streamed .td-released>*, 39 | .hide-films-unstreamed #diary-table tr.film-not-streamed .td-review>*, 40 | .hide-films-unstreamed #diary-table tr.film-not-streamed .td-rewatch>* { 41 | opacity: .2; 42 | transition: all .1s linear 43 | } 44 | 45 | .hide-films-unstreamed #diary-table tr.film-not-streamed:active .td-actions>*, 46 | .hide-films-unstreamed #diary-table tr.film-not-streamed:active .td-film-details>*, 47 | .hide-films-unstreamed #diary-table tr.film-not-streamed:active .td-like>*, 48 | .hide-films-unstreamed #diary-table tr.film-not-streamed:active .td-rating>*, 49 | .hide-films-unstreamed #diary-table tr.film-not-streamed:active .td-released>*, 50 | .hide-films-unstreamed #diary-table tr.film-not-streamed:active .td-review>*, 51 | .hide-films-unstreamed #diary-table tr.film-not-streamed:active .td-rewatch>*, 52 | .hide-films-unstreamed #diary-table tr.film-not-streamed:hover .td-actions>*, 53 | .hide-films-unstreamed #diary-table tr.film-not-streamed:hover .td-film-details>*, 54 | .hide-films-unstreamed #diary-table tr.film-not-streamed:hover .td-like>*, 55 | .hide-films-unstreamed #diary-table tr.film-not-streamed:hover .td-rating>*, 56 | .hide-films-unstreamed #diary-table tr.film-not-streamed:hover .td-released>*, 57 | .hide-films-unstreamed #diary-table tr.film-not-streamed:hover .td-review>*, 58 | .hide-films-unstreamed #diary-table tr.film-not-streamed:hover .td-rewatch>* { 59 | opacity: 1 60 | } -------------------------------------------------------------------------------- /extension/popup/popup.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | width: 300px; 3 | background: #FAFAFA; 4 | margin: 0; 5 | } 6 | 7 | .title{ 8 | margin: 5px; 9 | } 10 | 11 | .title-text{ 12 | padding: 2% 0; 13 | text-align: center; 14 | border: 3px dashed #FAFAFA; 15 | font-size: 16px; 16 | color: #000; 17 | font-weight: bold; 18 | font-family: Arial, Helvetica, sans-serif; 19 | } 20 | 21 | .title-img { 22 | float: left; 23 | } 24 | 25 | .content-text { 26 | font-family: Arial, Helvetica, sans-serif; 27 | padding: 3px; 28 | margin-left: 20px; 29 | } 30 | 31 | .select-box { 32 | width: 85%; 33 | padding: 5px; 34 | text-align: left; 35 | font-weight: bold; 36 | font-family: Arial, Helvetica, sans-serif; 37 | font-size: 16px; 38 | } 39 | 40 | #content { 41 | border-top: 1px solid; 42 | border-color: #BDBDBD; 43 | background-color: #F2F2F2; 44 | left: 0; 45 | right: 0; 46 | bottom: 0; 47 | height: 100%; 48 | width: 100%; 49 | } 50 | 51 | select { 52 | border: none; 53 | width: 100%; 54 | height: 40px; 55 | padding-left: 10px; 56 | margin-bottom: 10px; 57 | margin-left: 20px; 58 | margin-top: 0px; 59 | color: #666; 60 | font-family: Arial, Helvetica, sans-serif; 61 | font-size: 16px; 62 | box-shadow: 2px 2px 5px 1px rgba(0,0,0,0.3); 63 | border-radius: 3px; 64 | outline: none; 65 | cursor: pointer; 66 | } 67 | 68 | select option { 69 | color: #666; 70 | } 71 | 72 | #footer { 73 | border-top: 1px solid; 74 | border-color: #BDBDBD; 75 | } 76 | 77 | .footer-text { 78 | margin-top: 3px; 79 | margin-bottom: 3px; 80 | font-family: Arial, Helvetica, sans-serif; 81 | font-size: 12px; 82 | text-align: center; 83 | color: #666; 84 | } 85 | 86 | .justwatch-logo { 87 | text-align: center; 88 | } 89 | 90 | .justwatch-logo img { 91 | width:100px; 92 | height:15px; 93 | } 94 | 95 | /* The switch - the box around the slider */ 96 | .switch { 97 | position: relative; 98 | display: inline-block; 99 | width: 85%; 100 | height: 34px; 101 | margin-top: 10px; 102 | } 103 | 104 | /* Hide default HTML checkbox */ 105 | .switch input { 106 | opacity: 0; 107 | width: 0; 108 | height: 0; 109 | } 110 | 111 | /* The slider */ 112 | .slider { 113 | position: absolute; 114 | cursor: pointer; 115 | top: 0; 116 | left: 0; 117 | right: 0; 118 | bottom: 0; 119 | background-color: #ccc; 120 | -webkit-transition: .4s; 121 | transition: .4s; 122 | margin-left: 195px; 123 | } 124 | 125 | .slider:before { 126 | position: absolute; 127 | content: ""; 128 | height: 26px; 129 | width: 26px; 130 | left: 4px; 131 | bottom: 4px; 132 | background-color: white; 133 | -webkit-transition: .4s; 134 | transition: .4s; 135 | } 136 | 137 | input:checked + .slider { 138 | background-color: #2196F3; 139 | } 140 | 141 | input:focus + .slider { 142 | box-shadow: 0 0 1px #2196F3; 143 | } 144 | 145 | input:checked + .slider:before { 146 | -webkit-transform: translateX(26px); 147 | -ms-transform: translateX(26px); 148 | transform: translateX(26px); 149 | } 150 | 151 | /* Rounded sliders */ 152 | .slider.round { 153 | border-radius: 34px; 154 | } 155 | 156 | .slider.round:before { 157 | border-radius: 50%; 158 | } 159 | 160 | .slider-text { 161 | font-family: Arial, Helvetica, sans-serif; 162 | padding: 3px; 163 | margin-left: 25px; 164 | font-size: 16px; 165 | text-align: left; 166 | font-weight: bold; 167 | margin-top: 5px; 168 | } 169 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Letterboxd Streaming Providers ![Logo](./extension/icons/logo_final_48.png) 2 | 3 | [![Project status: active – The project has reached a stable, usable state and is being actively developed.](https://www.repostatus.org/badges/latest/active.svg)](https://www.repostatus.org/#active) 4 | [![Project releases](https://img.shields.io/github/release/adlerzei/letterboxd-streaming-providers)](https://github.com/adlerzei/letterboxd-streaming-providers/releases) 5 | [![Project contributors](https://img.shields.io/github/contributors/adlerzei/letterboxd-streaming-providers)](https://github.com/adlerzei/letterboxd-streaming-providers/graphs/contributors) 6 | [![Project license](https://img.shields.io/github/license/adlerzei/letterboxd-streaming-providers)](https://github.com/adlerzei/letterboxd-streaming-providers/blob/main/LICENSE) 7 | 8 | ## What? 9 | This is a extension for common web browsers coded using the WebExtensions API. 10 | 11 | ## Main Features 12 | This extension adds a filter for streaming providers (e.g., Netflix, Amazon Prime Video) to [Letterboxd](https://letterboxd.com/), to make it possible for you to see, which movies are included in your streaming flat rate. 13 | 14 | ### How? 15 | The extension uses the TMDb API to access the streaming information that is provided by JustWatch. 16 | 17 | ### Which browser to use? 18 | The extension can be added into Chrome, Firefox and Opera. 19 | 20 | #### Chrome Web Store 21 | [Letterboxd Streaming Providers at the Chrome Web Store](https://chrome.google.com/webstore/detail/letterboxd-streaming-prov/egmanfnfgmljjmdncfoeghfmflhlmhpj) 22 | 23 | #### Firefox Add-ons (AMO) 24 | **NOTE:** The Firefox version is currently broken. The Chrome version is running fine but it is based on Manifest v3. Maybe someone can port the Chrome version back to Manifest v2. If you are interested in doing this, contact me. 25 | 26 | [Letterboxd Streaming Providers at AMO](https://addons.mozilla.org/en-US/firefox/addon/letterboxd-streaming-providers/) 27 | 28 | ### Which countries are supported? 29 | All countries supported by JustWatch are also supported by this extension. 30 | 31 | At the time of writing, these are:
32 | Andorra, United Arab Emirates, Antigua and Barbuda, Albania, Angola, Argentina, Austria, Australia, Azerbaijan, Bosnia and Herzegovina, Barbados, Belgium, Burkina Faso, Bulgaria, Bahrain, Bermuda, Bolivia, Brazil, Bahamas, Belarus, Belize, Canada, Congo, Switzerland, Cote D'Ivoire, Chile, Cameroon, Colombia, Costa Rica, Cuba, Cape Verde, Cyprus, Czech Republic, Germany, Denmark, Dominican Republic, Algeria, Ecuador, Estonia, Egypt, Spain, Finland, Fiji, France, United Kingdom, French Guiana, Ghana, Gibraltar, Guadaloupe, Equatorial Guinea, Greece, Guatemala, Guyana, Hong Kong, Honduras, Croatia, Hungary, Indonesia, Ireland, Israel, India, Iraq, Iceland, Italy, Jamaica, Jordan, Japan, Kenya, South Korea, Kuwait, Lebanon, St. Lucia, Liechtenstein, Lithuania, Luxembourg, Latvia, Libyan Arab Jamahiriya, Morocco, Monaco, Moldova, Montenegro, Madagascar, Macedonia, Mali, Malta, Mauritius, Malawi, Mexico, Malaysia, Mozambique, Niger, Nigeria, Nicaragua, Netherlands, Norway, New Zealand, Oman, Panama, Peru, French Polynesia, Papua New Guinea, Philippines, Pakistan, Poland, Palestinian Territory, Portugal, Paraguay, Qatar, Romania, Serbia, Russia, Saudi Arabia, Seychelles, Sweden, Singapore, Slovenia, Slovakia, San Marino, Senegal, El Salvador, Turks and Caicos Islands, Chad, Thailand, Tunisia, Turkey, Trinidad and Tobago, Taiwan, Tanzania, Ukraine, Uganda, United States of America, Uruguay, Holy See, Venezuela, Kosovo, Yemen, South Africa, Zambia, Zimbabwe 33 | 34 | ### Important Notice 35 | This is a third party extension and is not related to the Letterboxd developer team in any way. This product uses the TMDb API but is not endorsed or certified by TMDb. The extension also uses information provided by JustWatch but is not endorsed or certified by JustWatch. 36 | 37 | ## Contributing 38 | 39 | ### Developing 40 | - `npm install` - Installs all dependencies. 41 | - `npm run build` - Builds the Firefox (.xpi) and the Chrome/Opera (.zip) builds. 42 | 43 | For the extension to work, you need to edit `./settings/api.json` and insert your TMDB API key. If you don't have one, you can request one [here](https://www.themoviedb.org/documentation/api). 44 | 45 | ### How to test? 46 | 1. Run `npm install` once at the beginning of your development. 47 | 2. Load the extension in your browser. 48 | 49 | In Chrome: 50 | - go to `chrome://extensions` 51 | - activate developer mode 52 | - then 53 | - click `load unpacked extension` 54 | - load the `/extension` folder 55 | - or 56 | - drag & drop the Chrome build file from `/builds` into the tab. 57 | 58 | In Firefox: 59 | - go to `about:debugging` 60 | - then 61 | - load `extension/manifest.json` 62 | - or 63 | - load the Firefox build file from `/builds`. 64 | 65 | ### Donations 66 | If you like my work, you can support me via [PayPal](https://www.paypal.me/ChristianZei/5). Thank you! 67 | 68 | ## Acknowledgements 69 | Thanks to everyone using, supporting and contributing to the extension. Philipp Emmer is especially mentioned for the idea behind this extension. 70 | 71 | ## Contributors 72 | 73 | 74 | 75 | 76 | Made with [contributors-img](https://contributors-img.web.app). 77 | -------------------------------------------------------------------------------- /extension/popup/popup.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | //for compatibility reasons 4 | var browser = chrome; 5 | 6 | var countries = {}; 7 | var providers = {}; 8 | var providerId = 0; 9 | var countryCode = ''; 10 | var filterStatus = false; 11 | 12 | var countryList = document.getElementById('CountryList'); 13 | var providerList = document.getElementById('ProviderList'); 14 | var filterSwitch = document.getElementById("filterSwitch"); 15 | 16 | // load stored settings from localStorage 17 | browser.storage.local.get((items) => { 18 | parseSettings(items); 19 | 20 | // load cached variables from sessionStorage 21 | browser.storage.session.get((items) => { 22 | parseCache(items); 23 | 24 | appendOptionsToCountryList(); 25 | 26 | appendOptionsToProviderList(); 27 | 28 | filterSwitch.checked = filterStatus; 29 | 30 | filterSwitch.addEventListener("change", changeFilterSwitch); 31 | providerList.addEventListener("change", changeProviderId); 32 | countryList.addEventListener("change", changeCountryCode); 33 | }); 34 | }); 35 | 36 | /** 37 | * Appends all countries as option to the countryList select tag. 38 | */ 39 | function appendOptionsToCountryList() { 40 | var fragment = document.createDocumentFragment(); 41 | var keys = Object.keys(countries).sort(function (a, b) { 42 | return ('' + countries[a].name).localeCompare(countries[b].name); 43 | }); 44 | for (const country of keys) { 45 | if (!countries[country].hasOwnProperty('name') || !countries[country].hasOwnProperty('code')) 46 | continue; 47 | 48 | let opt = document.createElement('option'); 49 | opt.insertAdjacentHTML('beforeend', countries[country].name); 50 | opt.value = country; 51 | opt.label = countries[country].name; 52 | if (countries[country].code === countryCode) { 53 | opt.selected = "selected"; 54 | } 55 | fragment.appendChild(opt); 56 | } 57 | countryList.appendChild(fragment); 58 | } 59 | 60 | /** 61 | * Appends all providers from the selected country as option to the providerList select tag. 62 | * 63 | * param {string} [defaultProviderName] - The (optional) name of the provider which is selected by default. 64 | */ 65 | function appendOptionsToProviderList(defaultProviderName) { 66 | providerList.options.length = 0; 67 | let fragment = document.createDocumentFragment(); 68 | let keys = Object.keys(providers).sort(function (a, b) { 69 | return ('' + providers[a].name).localeCompare(providers[b].name); 70 | }); 71 | for (const provider of keys) { 72 | if (!providers[provider].hasOwnProperty('name') || !providers[provider].hasOwnProperty('provider_id')) 73 | continue; 74 | 75 | let country = countryList.options[countryList.selectedIndex].value; 76 | if (providers[provider].countries.includes(country)) { 77 | let opt = document.createElement('option'); 78 | opt.insertAdjacentHTML('beforeend', providers[provider].name); 79 | opt.value = provider; 80 | opt.label = providers[provider].name; 81 | if (typeof defaultProviderName === 'undefined') { 82 | if (providers[provider].provider_id === providerId) { 83 | opt.selected = "selected"; 84 | } 85 | } else { 86 | if (providers[provider].name === defaultProviderName) { 87 | opt.selected = "selected"; 88 | } 89 | } 90 | fragment.appendChild(opt); 91 | } 92 | } 93 | providerList.appendChild(fragment); 94 | } 95 | 96 | /** 97 | * Changes the filter status in the background page. 98 | */ 99 | function changeFilterSwitch() { 100 | // enable or disable filtering 101 | filterStatus = filterSwitch.checked; 102 | browser.storage.local.set({filter_status: filterSwitch.checked}); 103 | providerList.disabled = (!filterSwitch.checked); 104 | countryList.disabled = (!filterSwitch.checked); 105 | } 106 | 107 | /** 108 | * Called when the selected item in providerList is changed. Changes the provider_id in the background page. 109 | */ 110 | function changeProviderId() { 111 | let id = providerList.options[providerList.selectedIndex].value; 112 | if (typeof providers !== 'undefined' && providers.hasOwnProperty(id) && providers[id].hasOwnProperty('provider_id')) { 113 | providerId = providers[id].provider_id; 114 | browser.storage.local.set({provider_id: providerId}); 115 | } 116 | } 117 | 118 | /** 119 | * Called when the selected item in countryList is changed. 120 | * Changes the country code in the background page and forces the options in providerList to reload. 121 | */ 122 | function changeCountryCode() { 123 | let code = countryList.options[countryList.selectedIndex].value; 124 | if (typeof countries !== 'undefined' && countries.hasOwnProperty(code) 125 | && countries[code].hasOwnProperty('code')) { 126 | countryCode = countries[code].code; 127 | 128 | browser.storage.local.set({ 129 | country_code: countryCode 130 | }); 131 | 132 | let defaultProviderId = providerList.options[providerList.selectedIndex].label; 133 | appendOptionsToProviderList(defaultProviderId); 134 | changeProviderId(); 135 | } 136 | } 137 | 138 | function parseSettings(items) { 139 | countryCode = items.hasOwnProperty('country_code') ? items.country_code : 'US'; 140 | providerId = items.hasOwnProperty('provider_id') ? items.provider_id : 8; 141 | filterStatus = items.hasOwnProperty('filter_status') ? items.filter_status : false; 142 | } 143 | 144 | function parseCache(items) { 145 | providers = items.hasOwnProperty('providers') ? items.providers : {}; 146 | countries = items.hasOwnProperty('countries') ? items.countries : {}; 147 | } 148 | 149 | /** 150 | * Returns the current browser name. 151 | * 152 | * @returns {string} - The browser's name. 153 | */ 154 | function getBrowser() { 155 | // Opera 8.0+ 156 | let isOpera = (!!window.opr && !!opr.addons) || !!window.opera || navigator.userAgent.indexOf(' OPR/') >= 0; 157 | 158 | // Firefox 1.0+ 159 | let isFirefox = typeof InstallTrigger !== 'undefined'; 160 | 161 | // Chrome 1 - 71 162 | let isChrome = !!window.chrome && (!!window.chrome.webstore || !!window.chrome.runtime); 163 | 164 | return isOpera ? 'Opera' : 165 | isFirefox ? 'Firefox' : 166 | isChrome ? 'Chrome' : 167 | "Don't know"; 168 | } 169 | 170 | if (getBrowser() !== 'Firefox') { 171 | // for opening the hyperlink in the popup in a new tab 172 | document.addEventListener('DOMContentLoaded', function () { 173 | var links = document.getElementsByTagName("a"); 174 | for (var i = 0; i < links.length; i++) { 175 | (function () { 176 | var ln = links[i]; 177 | var location = ln.href; 178 | ln.onclick = function () { 179 | browser.tabs.create({ 180 | active: true, 181 | url: location 182 | }); 183 | }; 184 | })(); 185 | } 186 | }); 187 | } 188 | -------------------------------------------------------------------------------- /extension/background.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // for compatibility reasons 4 | var browser = chrome; 5 | 6 | // settings 7 | var countryCode = ''; // e.g. German: "DE", USA: "US" 8 | var providerId = 0; // e.g. Netflix: 8, Amazon Prime Video: 9 9 | var filterStatus = false; 10 | 11 | // fetch options 12 | var fetchOptions = {}; 13 | 14 | // cache 15 | var providers = {}; 16 | var countries = {}; 17 | 18 | var availableMovies = {}; 19 | var crawledMovies = {}; 20 | var unsolvedRequests = {}; 21 | 22 | // contains the number of already checked movies per tab 23 | var checkCounter = {}; 24 | 25 | var reloadActive = {}; 26 | 27 | 28 | ///////////////////////////////////////////////////////////////////////////////////// 29 | ///////////////////////// STARTUP AND SETTINGS ////////////////////////////////////// 30 | ///////////////////////////////////////////////////////////////////////////////////// 31 | 32 | /** 33 | * Loads all information from JSON files for intern computations. 34 | * Requests the available countries and providers from TMDB. 35 | * Also loads the current settings. 36 | * 37 | * @returns {Promise} - An empty Promise if the loadings worked correctly, else the Promise contains the respective errors. 38 | */ 39 | const onStartUp = async () => { 40 | // load TMDb token and set fetch options 41 | await loadJson("settings/api.json", function (json) { 42 | setFetchOptions(json.tmdb); 43 | 44 | // persist for later service worker cycles 45 | browser.storage.session.set({tmdb_token: json.tmdb}); 46 | }); 47 | 48 | // load stored settings from localStorage 49 | browser.storage.local.get(parseSettings); 50 | 51 | await requestRegions(); 52 | 53 | await requestProviderList(); 54 | 55 | async function requestRegions() { 56 | let url = "https://api.themoviedb.org/3/watch/providers/regions"; 57 | let response = await fetch(url, fetchOptions); 58 | if (response.status == 200) { 59 | let rsp = await response.json(); 60 | let str = "" 61 | for(const entry of rsp.results) { 62 | countries[entry.iso_3166_1] = { 63 | 'code': entry.iso_3166_1, 64 | 'name': entry.english_name 65 | }; 66 | str += entry.english_name + ", " 67 | } 68 | 69 | console.log(str); 70 | } 71 | 72 | // persist for later service worker cycles 73 | browser.storage.session.set({countries: countries}); 74 | } 75 | 76 | async function requestProviderList() { 77 | let url = "https://api.themoviedb.org/3/watch/providers/movie?language=en-US"; 78 | let response = await fetch(url, fetchOptions); 79 | if (response.status == 200) { 80 | let rsp = await response.json(); 81 | for(const entry of rsp.results) { 82 | providers[entry.provider_id] = { 83 | 'provider_id': entry.provider_id, 84 | 'name': entry.provider_name.trim(), 85 | 'display_priority': entry.display_priority, 86 | 'countries': Object.keys(entry.display_priorities) 87 | }; 88 | } 89 | } 90 | 91 | // persist for later service worker cycles 92 | browser.storage.session.set({providers: providers}); 93 | } 94 | }; 95 | 96 | function parseSettings(items) { 97 | let countryCodeSet = false; 98 | let providerSet = false; 99 | let statusSet = false; 100 | 101 | if (items.hasOwnProperty('country_code')) { 102 | countryCodeSet = true; 103 | countryCode = items.country_code; 104 | } 105 | if (items.hasOwnProperty('provider_id')) { 106 | providerSet = true; 107 | providerId = items.provider_id; 108 | } 109 | if (items.hasOwnProperty('filter_status')) { 110 | statusSet = true; 111 | filterStatus = items.filter_status; 112 | } 113 | 114 | if ((!countryCodeSet) || (!providerSet) || (!statusSet)) { 115 | loadDefaultSettings(countryCodeSet, providerSet, statusSet); 116 | } 117 | } 118 | 119 | function loadDefaultSettings(countryCodeSet, providerSet, statusSet) { 120 | // load default settings 121 | loadJson("settings/default.json", function (json) { 122 | // set the intern settings 123 | if (!countryCodeSet && json.hasOwnProperty('country_code')) { 124 | countryCode = json.country_code; 125 | } 126 | if (!providerSet && json.hasOwnProperty('provider_id')) { 127 | providerId = json.provider_id; 128 | } 129 | if (!statusSet && json.hasOwnProperty('filter_status')) { 130 | filterStatus = json.filter_status; 131 | } 132 | }); 133 | } 134 | 135 | function parseCache(items) { 136 | providers = items.hasOwnProperty('providers') ? items.providers : {}; 137 | countries = items.hasOwnProperty('countries') ? items.countries : {}; 138 | 139 | availableMovies = items.hasOwnProperty('available_movies') ? items.available_movies : {}; 140 | crawledMovies = items.hasOwnProperty('crawled_movies') ? items.crawled_movies : {}; 141 | unsolvedRequests = items.hasOwnProperty('unsolved_requests') ? items.unsolved_requests : {}; 142 | let tmdbToken = items.hasOwnProperty('tmdb_token') ? items.tmdb_token : ''; 143 | setFetchOptions(tmdbToken); 144 | 145 | checkCounter = items.hasOwnProperty('check_counter') ? items.check_counter : {}; 146 | 147 | reloadActive = items.hasOwnProperty('reload_active') ? items.reload_active : {}; 148 | } 149 | 150 | ///////////////////////////////////////////////////////////////////////////////////// 151 | /////////////////////////// EVENT LISTENER ////////////////////////////////////////// 152 | ///////////////////////////////////////////////////////////////////////////////////// 153 | 154 | browser.runtime.onInstalled.addListener(() => onStartUp()); 155 | browser.runtime.onStartup.addListener(() => onStartUp()); 156 | 157 | browser.runtime.onMessage.addListener((request, sender, sendResponse) => { 158 | // load stored settings from localStorage 159 | browser.storage.local.get((items) => { 160 | parseSettings(items); 161 | // load cached variables from sessionStorage 162 | browser.storage.session.get((items) => { 163 | parseCache(items); 164 | handleMessage(request, sender); 165 | }); 166 | }); 167 | }); 168 | 169 | browser.tabs.onUpdated.addListener((tabId, changeInfo, tabInfo) => { 170 | // load stored settings from localStorage 171 | browser.storage.local.get((items) => { 172 | parseSettings(items); 173 | // load cached variables from sessionStorage 174 | browser.storage.session.get((items) => { 175 | parseCache(items); 176 | 177 | checkLetterboxdForPageReload(tabId, changeInfo, tabInfo); 178 | }); 179 | }); 180 | }); 181 | 182 | browser.storage.local.onChanged.addListener(_ => { 183 | // load stored settings from localStorage 184 | browser.storage.local.get((items) => { 185 | parseSettings(items); 186 | // load cached variables from sessionStorage 187 | browser.storage.session.get((items) => { 188 | parseCache(items); 189 | 190 | reloadMovieFilter(); 191 | }); 192 | }); 193 | }); 194 | 195 | browser.alarms.onAlarm.addListener(alarm => { 196 | if (alarm.name != "handleUnsolvedRequests") 197 | return; 198 | 199 | // load stored settings from localStorage 200 | browser.storage.local.get((items) => { 201 | parseSettings(items); 202 | // load cached variables from sessionStorage 203 | browser.storage.session.get((items) => { 204 | parseCache(items); 205 | 206 | var movies = JSON.parse(JSON.stringify(unsolvedRequests[tabId])); 207 | // decrease the counter by the number of unsolved requests 208 | // we will try to solve them now 209 | checkCounter[tabId] = checkCounter[tabId] - Object.keys(unsolvedRequests[tabId]).length; 210 | unsolvedRequests[tabId] = {}; 211 | 212 | // persist for later service worker cycles 213 | browser.storage.session.set({ 214 | check_counter: checkCounter, 215 | unsolved_requests: unsolvedRequests 216 | }); 217 | 218 | checkMovieAvailability(tabId, movies); 219 | }); 220 | }); 221 | }); 222 | 223 | ///////////////////////////////////////////////////////////////////////////////////// 224 | ////////////////////////////////// RELOAD /////////////////////////////////////////// 225 | ///////////////////////////////////////////////////////////////////////////////////// 226 | 227 | /** 228 | * Called to force the filters to reload with the new provider ID. 229 | */ 230 | function reloadMovieFilter() { 231 | browser.tabs.query({}, reloadFilterInTab); 232 | 233 | function reloadFilterInTab(tabs) { 234 | for (const tab of tabs) { 235 | let tabId = tab.id; 236 | 237 | if (reloadActive[tabId]) 238 | continue; 239 | 240 | reloadActive[tabId] = true; 241 | 242 | // persist for later service worker cycles 243 | browser.storage.session.set({reload_active: reloadActive}); 244 | 245 | let changeInfo = { 246 | status: 'complete' 247 | }; 248 | let tabInfo = { 249 | url: tab.url 250 | }; 251 | 252 | // unfadeUnstreamedMovies(tabId, crawledMovies[tabId]); 253 | unfadeAllMovies(tabId); 254 | checkForLetterboxd(tabId, changeInfo, tabInfo); 255 | } 256 | } 257 | } 258 | 259 | ///////////////////////////////////////////////////////////////////////////////////// 260 | /////////////////////////// MOVIE AVAILABILITY ////////////////////////////////////// 261 | ///////////////////////////////////////////////////////////////////////////////////// 262 | 263 | /** 264 | * Called from within the listener for new messages from the content script. 265 | * Triggers check for movie availability or re-initiates the whole process if no movies received. 266 | * 267 | * @param {{messageType: string, messageContent: object}} request - The message from the content script. 268 | * @param {object} sender - The sender from the runtime.onMessage event. 269 | */ 270 | function handleMessage(request, sender) { 271 | var tabId; 272 | if (sender.hasOwnProperty('tab') && sender.tab.hasOwnProperty('id')) { 273 | tabId = sender.tab.id; 274 | } else { 275 | console.log("Error: missing tab ID"); 276 | } 277 | if (request.hasOwnProperty('messageType') && request.hasOwnProperty('messageContent')) { 278 | if (request.messageType === 'movie-titles') { 279 | crawledMovies[tabId] = request.messageContent; 280 | 281 | // persist for later service worker cycles 282 | browser.storage.session.set({crawled_movies: crawledMovies}); 283 | 284 | if (Object.keys(crawledMovies[tabId]).length === 0) { 285 | // we don't got any movies yet, let's try again 286 | getFilmsFromLetterboxd(tabId); 287 | } else { 288 | checkMovieAvailability(tabId, crawledMovies[tabId]); 289 | } 290 | } 291 | } 292 | } 293 | 294 | /** 295 | * Calls the method for checking the movie availability for each movie in movies. 296 | * 297 | * @param {int} tabId - The tabId to operate in. 298 | * @param {object} movies - The crawled movies from Letterboxed. 299 | */ 300 | function checkMovieAvailability(tabId, movies) { 301 | if (filterStatus) { 302 | prepareLetterboxdForFading(tabId); 303 | for (const movie in movies) { 304 | isIncluded(tabId, { 305 | title: movie, 306 | year: movies[movie].year, 307 | id: movies[movie].id 308 | }); 309 | } 310 | } else { 311 | reloadActive[tabId] = false; 312 | 313 | // persist for later service worker cycles 314 | browser.storage.session.set({reload_active: reloadActive}); 315 | } 316 | } 317 | 318 | /** 319 | * Checks if a movie is available and adds it to availableMovies[tabId]. 320 | * 321 | * @param {object} toFind - An object, which contains the movie title, the release year and the Letterboxd-intern array id. 322 | * @param {int} tabId - The tabId of the tab, in which Letterboxd should be filtered. 323 | * @returns {Promise} - An empty Promise if the API calls worked correctly, else the Promise contains the respective errors. 324 | */ 325 | async function isIncluded(tabId, toFind) { 326 | let englishTitle = toFind.title; 327 | let releaseYear = toFind.year; 328 | let letterboxdId = toFind.id; 329 | let titleSanitized = encodeURIComponent(englishTitle); 330 | 331 | let url = `https://api.themoviedb.org/3/search/multi?query=${titleSanitized}`; 332 | let response = await fetch(url, fetchOptions); 333 | 334 | if (response.status != 200) { 335 | // something went wrong during the request 336 | 337 | // if there are too many requests: try again later 338 | if (response.status == 429) { 339 | unsolvedRequests[tabId][englishTitle] = { 340 | year: releaseYear, 341 | id: letterboxdId 342 | }; 343 | 344 | // persist for later service worker cycles 345 | browser.storage.session.set({unsolved_requests: unsolvedRequests}); 346 | } 347 | 348 | increaseCheckCounter(tabId); 349 | return; 350 | } 351 | 352 | let rsp = await response.json(); 353 | let tmdbInfo = getIdWithReleaseYear(rsp.results, englishTitle, releaseYear); 354 | 355 | if (!tmdbInfo.matchFound) { 356 | // this time we are unlucky and don't find any match 357 | increaseCheckCounter(tabId); 358 | return; 359 | } 360 | 361 | // we have found what we are looking for 362 | // now check the provider availability 363 | url = `https://api.themoviedb.org/3/${tmdbInfo.mediaType}/${tmdbInfo.tmdbId}/watch/providers`; 364 | response = await fetch(url, fetchOptions); 365 | if (response.status != 200) { 366 | // something went wrong during the request 367 | 368 | // if there are too many requests: try again later 369 | if (response.status == 429) { 370 | unsolvedRequests[tabId][englishTitle] = { 371 | year: releaseYear, 372 | id: letterboxdId 373 | }; 374 | 375 | // persist for later service worker cycles 376 | browser.storage.session.set({unsolved_requests: unsolvedRequests}); 377 | } 378 | 379 | increaseCheckCounter(tabId); 380 | return; 381 | } 382 | 383 | rsp = await response.json(); 384 | addMovieIfFlatrate(rsp.results, tabId, letterboxdId); 385 | increaseCheckCounter(tabId); 386 | } 387 | 388 | /** 389 | * Returns the TMDb ID for a given English media title and a corresponding release year. 390 | * If no exact match is found (i.e., title and release year do not match exactly), 391 | * this function tries to find a match with best effort: 392 | * maybe the release year differs by 1 or is missing completely. 393 | * 394 | * @param {object} results - The results from the TMDB "Multi" request. 395 | * @param {string} titleEnglish - The English movie title. 396 | * @param {int} releaseYear - The media's release year. 397 | * @returns {{tmdbId: int, mediaType: string, matchFound: boolean}} - An object containing the TMDB movie ID, the media type and if this was a perfect match (titleEnglish and movie_release_year match up). 398 | */ 399 | function getIdWithReleaseYear(results, titleEnglish, releaseYear) { 400 | let candidate = { 401 | tmdbId: -1, 402 | mediaType: '', 403 | matchFound: false 404 | }; 405 | 406 | for (let item in results) { 407 | if (!results[item].hasOwnProperty('media_type')) 408 | continue; 409 | 410 | let itemTitle = ''; 411 | let itemReleaseDate = ''; 412 | if (results[item].media_type == 'movie') { 413 | if (!results[item].hasOwnProperty('release_date') || !results[item].hasOwnProperty('title')) 414 | continue; 415 | 416 | itemTitle = results[item].title; 417 | itemReleaseDate = results[item].release_date; 418 | } else if (results[item].media_type == 'tv') { 419 | if (!results[item].hasOwnProperty('first_air_date') || !results[item].hasOwnProperty('name')) 420 | continue; 421 | 422 | itemTitle = results[item].name; 423 | itemReleaseDate = results[item].first_air_date; 424 | } else { 425 | continue; 426 | } 427 | 428 | let itemReleaseYear = new Date(itemReleaseDate).getFullYear(); 429 | 430 | if (itemTitle.toLowerCase() == titleEnglish.toLowerCase()) { 431 | if (itemReleaseYear == releaseYear) { 432 | return { 433 | tmdbId: results[item].id, 434 | mediaType: results[item].media_type, 435 | matchFound: true 436 | }; 437 | } else if (releaseYear == -1 || 438 | itemReleaseYear == releaseYear - 1 || 439 | itemReleaseYear == releaseYear + 1) { 440 | candidate = { 441 | tmdbId: results[item].id, 442 | mediaType: results[item].media_type, 443 | matchFound: true 444 | }; 445 | } 446 | } 447 | } 448 | 449 | return candidate; 450 | } 451 | 452 | /** 453 | * Adds the given letterboxd ID to the availableMovies 454 | * if the selected provider includes the movie in its flatrate 455 | * 456 | * @param {object} results - The results from the TMDB "Watch Providers" request. 457 | * @param {string} tabId - The tabId to operate in. 458 | * @param {int} letterboxdId - The intern ID from the array in letterboxd.com. 459 | */ 460 | function addMovieIfFlatrate(results, tabId, letterboxdId) { 461 | if (!(countryCode in results) || !results[countryCode].hasOwnProperty('flatrate')) { 462 | return; 463 | } 464 | 465 | for (const offer of results[countryCode].flatrate) { 466 | if (!offer.hasOwnProperty('provider_id')) { 467 | continue; 468 | } 469 | 470 | if (offer.provider_id == providerId) { 471 | availableMovies[tabId].push(...letterboxdId); 472 | return; 473 | } 474 | } 475 | } 476 | 477 | ///////////////////////////////////////////////////////////////////////////////////// 478 | //////////////////////// GET MOVIES FROM LETTERBOXD ///////////////////////////////// 479 | ///////////////////////////////////////////////////////////////////////////////////// 480 | 481 | /** 482 | * Waits for a short delay and then calls checkForLetterboxd. 483 | * 484 | * @param {int} tabId - The tabId to operate in. 485 | * @param {object} changeInfo - The changeInfo from the tabs.onUpdated event. 486 | * @param {object} tabInfo - The tabInfo from the tabs.onUpdated event. 487 | */ 488 | function checkLetterboxdForPageReload(tabId, changeInfo, tabInfo) { 489 | // short timeout, wait for the page to load all release years (and other movie info) 490 | // using timeouts is not recommended in combination with service workers acc. to Google, 491 | // bc. the service workers may stop terminate during the timeout 492 | // (see https://developer.chrome.com/docs/extensions/mv3/migrating_to_service_workers/#alarms). 493 | // However, alarm API does not allow such short timeouts and it does not work without it 494 | setTimeout(function () { 495 | checkForLetterboxd(tabId, changeInfo, tabInfo); 496 | }, 500); 497 | } 498 | 499 | /** 500 | * Checks if the current URL of the tab matches the given pattern. 501 | * 502 | * @param {int} tabId - The tabId to operate in. 503 | * @param {object} changeInfo - The changeInfo from the tabs.onUpdated event. 504 | * @param {object} tabInfo - The tabInfo from the tabs.onUpdated event. 505 | */ 506 | function checkForLetterboxd(tabId, changeInfo, tabInfo) { 507 | if (filterStatus) { 508 | if (changeInfo.hasOwnProperty('status') && changeInfo.status === 'complete') { 509 | let url = tabInfo.url; 510 | if (url.includes("://letterboxd.com/") || url.includes("://www.letterboxd.com/")) { 511 | if (url.includes('/watchlist/') || url.includes('/films/') || url.includes('/likes/') || url.includes('/list/')) { // || url === "https://letterboxd.com/" || url === 'https://www.letterboxd.com/' 512 | checkCounter[tabId] = 0; 513 | availableMovies[tabId] = []; 514 | crawledMovies[tabId] = {}; 515 | unsolvedRequests[tabId] = {}; 516 | 517 | // persist for later service worker cycles 518 | browser.storage.session.set({ 519 | check_counter: checkCounter, 520 | available_movies: availableMovies, 521 | crawled_movies: crawledMovies, 522 | unsolved_requests: unsolvedRequests, 523 | }); 524 | 525 | getFilmsFromLetterboxd(tabId); 526 | } 527 | } 528 | } 529 | } else { 530 | reloadActive[tabId] = false; 531 | 532 | // persist for later service worker cycles 533 | browser.storage.session.set({reload_active: reloadActive}); 534 | } 535 | } 536 | 537 | /** 538 | * Injects a content script into the Letterboxd web page to crawl the movie titles and release years. 539 | * 540 | * @param tabId - The tabId to operate in. 541 | */ 542 | function getFilmsFromLetterboxd(tabId) { 543 | browser.tabs.get(tabId, (tab) => { 544 | let fileName = "./scripts/getFilmsFromLetterboxd.js"; 545 | 546 | browser.scripting.executeScript({ 547 | target: { 548 | tabId: tabId, 549 | allFrames: true 550 | }, 551 | files: [fileName] 552 | }); 553 | }); 554 | } 555 | 556 | ///////////////////////////////////////////////////////////////////////////////////// 557 | ///////////////////////////// FADING //////////////////////////////////////////////// 558 | ///////////////////////////////////////////////////////////////////////////////////// 559 | 560 | /** 561 | * Inserts CSS and a corresponding content script in Letterboxd to add a new class and its style sheets. 562 | * 563 | * @param tabId - The tabId to operate in. 564 | */ 565 | function prepareLetterboxdForFading(tabId) { 566 | browser.scripting.insertCSS({ 567 | files: ["./style/hideunstreamed.css"], 568 | target: { 569 | tabId: tabId, 570 | allFrames: false 571 | }, 572 | }, 573 | () => { 574 | let fileName = "./scripts/prepareLetterboxdForFading.js"; 575 | 576 | browser.scripting.executeScript({ 577 | target: { 578 | tabId: tabId, 579 | allFrames: false 580 | }, 581 | files: [fileName] 582 | }); 583 | }); 584 | } 585 | 586 | /** 587 | * Inserts a content script for unfading all unavailable movies, 588 | * 589 | * @param tabId - The tabId to operate in. 590 | * @param movies - The crawled movies. 591 | */ 592 | function fadeUnstreamableMovies(tabId, movies) { 593 | var className = 'poster-container'; 594 | 595 | function fadeOut(className, movieId) { 596 | filmposters = document.body.getElementsByClassName(className); 597 | filmposters[movieId].className += ' film-not-streamed'; 598 | } 599 | 600 | for (const movie in movies) { 601 | for (const movie_id of movies[movie].id) { 602 | if (!availableMovies[tabId].includes(movie_id)) { 603 | browser.scripting.executeScript({ 604 | target: { 605 | tabId: tabId, 606 | allFrames: false 607 | }, 608 | func: fadeOut, 609 | args: [className, movie_id], 610 | }); 611 | } 612 | } 613 | } 614 | 615 | // if there are unsolved requests left: solve them 616 | if (Object.keys(unsolvedRequests[tabId]).length != 0) { 617 | // but first wait for a delay to limit the traffic 618 | browser.alarms.create("handleUnsolvedRequests", { 619 | delayInMinutes: 1 620 | }); 621 | } 622 | 623 | reloadActive[tabId] = false; 624 | 625 | // persist for later service worker cycles 626 | browser.storage.session.set({reload_active: reloadActive}); 627 | } 628 | 629 | /** 630 | * Inserts a content script to unfade all movies on Letterboxd. 631 | * 632 | * @param tabId - The tabId to operate in. 633 | */ 634 | function unfadeAllMovies(tabId) { 635 | browser.tabs.get(tabId, (tab) => { 636 | if (!tab.url.includes('://letterboxd.com/') && !tab.url.includes('://www.letterboxd.com/')) 637 | return; 638 | 639 | var className = 'poster-container'; 640 | 641 | function unfade(className) { 642 | filmposters = document.body.getElementsByClassName(className); 643 | for(const poster in filmposters) { 644 | filmposters[poster].className = filmposters[poster].className.replace(' film-not-streamed', ''); 645 | } 646 | } 647 | 648 | browser.scripting.executeScript({ 649 | target: { 650 | tabId: tabId, 651 | allFrames: false 652 | }, 653 | func: unfade, 654 | args: [className], 655 | }); 656 | }); 657 | } 658 | 659 | ///////////////////////////////////////////////////////////////////////////////////// 660 | //////////////////////////// HELPERS //////////////////////////////////////////////// 661 | ///////////////////////////////////////////////////////////////////////////////////// 662 | 663 | /** 664 | * Returns the API access token for TMDb. 665 | * 666 | * @returns {string} - The API access token. 667 | */ 668 | function getApiToken() { 669 | return tmdbToken; 670 | } 671 | 672 | /** 673 | * Called to load a JSON file. 674 | * 675 | * @param {string} path - The path to the JSON file. 676 | * @param {function} callback - A callback function, which is called after loading the file successfully. 677 | */ 678 | const loadJson = async (path, callback) => { 679 | let response = await fetch(path); 680 | 681 | if (response.status == 200) { 682 | callback(await response.json()); 683 | } 684 | }; 685 | 686 | function increaseCheckCounter(tabId) { 687 | checkCounter[tabId]++; 688 | 689 | // persist for later service worker cycles 690 | browser.storage.session.set({check_counter: checkCounter}); 691 | 692 | if (checkCounter[tabId] == Object.keys(crawledMovies[tabId]).length) { 693 | fadeUnstreamableMovies(tabId, crawledMovies[tabId]); 694 | } 695 | } 696 | 697 | function setFetchOptions(token) { 698 | fetchOptions = { 699 | method: 'GET', 700 | headers: { 701 | "Authorization": "Bearer " + token, 702 | "Accept": "application/json" 703 | } 704 | } 705 | } 706 | --------------------------------------------------------------------------------