Plex Letterboxd link and rating

Add Letterboxd link and rating to its corresponding Plex film's page

当前为 2024-01-12 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Plex Letterboxd link and rating
  3. // @namespace http://tampermonkey.net/
  4. // @description Add Letterboxd link and rating to its corresponding Plex film's page
  5. // @author CarnivalHipster
  6. // @match https://app.plex.tv/*
  7. // @icon https://www.google.com/s2/favicons?sz=64&domain=plex.tv
  8. // @license MIT
  9. // @grant GM_xmlhttpRequest
  10. // @connect letterboxd.com
  11. // @version 2.4.5
  12. // ==/UserScript==
  13. (function() {
  14. 'use strict';
  15. const letterboxdImg = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAI1klEQVR4nO2bW3CTxxXH/2d1tSzJMrZFC8VcrBoSGyjFTmpoG0oNlEyHMjQyhelMiZtxzQMtfaB56Esf2mkb0gHS6eA4HTdtEwasNNNhQhJsBzN0nASwi3GwxxBjLsYEfL9JtiX5O30QFroif5Is25Tf07dnd8+es9pvtXt2P8I0s9W6L0Mi17cl5hwGLydQNgjpzDAQ2AAADBomwjAYPQy+RqCrgqhZsOrcB7Y/d0+nfTQdSjdb965lSLsZvAlAbgztMIArBKomiGNVtqMN8bPSQ9w6YFvxrwyjIwMlzFQM8NPx0usPtRBxRZLeVH6y4pXhuGiMVcH2PftNDsfYfgb/nJlT42FUJIion0Cv6XTaw/9+8/BATLqircjMtOlHpXtI4j8ykBGLEdFCQDcLern6eNmbRMRR6pBPobUkk4iOMfP6aOrHGyKqY+bdNbby27Lryq2wqah0PZjfZbBZbt3phEBdINpRXVlWJ6eekFN4c1FpMVg6M9ucBwAGm8HSmc1FpcVy6k1pBFitlYp+qjkIxi+jMy/BEA6lcuEBm61oInLRCFitlYoB1LzLwLb4WJcYCDhpQuGOSJ0Q8RXop5qDc815AGBgWz/VHIxUTvGozM1FpcXM/Pv4mZVwCiy5+R3Xm+svhSsQ9hXwzPbSGQbU02NbYiDACRIbw/07hOyAQmtJJoEuzsbZPhoI1MXg/FDrhKA5gJmJiI49Ls4Dnr/IBwu3oB88aA6oa733Ipj3Jca0hJL5z3+dutXe3NDoK/Trke179pvsdse1mVrbTzcEdCcn67J9N1B+r4BnV/d4Og8ADGQ4HGP7fWXeEeDZzw/eStSWdqYgov4kfcriyXiCdwR4ghmPt/MAwMypoyMDJZNp8TCDZG0i5jK+vhLgieFJmKiPVFGQhGXaPmQoR6BXODHg1uK+y4Db4/IGjlPHsH/ZjXGjJ4ahGSIkf6GE2iFzd546H6xPBesMwPgoaGQA1NcJSJFjIwKKvCrb0QYlADCk3Y8qvEAziF1pjVhnvAmjYjwo/75Lj9pBCyp7V2HYrQ2pgwF0rXaiY904Bpa6gcAADhNMN5RY9LEG5svq8EtUrQ7S6o1gyxqwPrjjacwOutkM0VgDDPaE9emBzw0EAIXWkiYAKwMLCZLworkeL6Q1QUlSWGWT2CU1jn5RgNMDy/3kjnkSrvx4BEOL3BF1AICxQ4nct/TQ9fmv03j5M5DWbQerQ3eyH5IE0XQW4uKpcCPisxpb+Sraat2X4cL4fQSsCdTCjd8sqkK+/s6UjPblnd6VeP1eAQBgYLEbl4uH4dLJC9mpHITVFQaYbik9/hRsg7Rqg2xbqKMViqq/AW5XYBaroJmvyMr9+lYG7wzMfXlhLdYbb8luEACe1nVhjJWo15rx371Dsp0HAEkFdOc6YW7SQLF8I6S1W6KyBSnp4JR0iPamwBwi4vNCYs4JzPleaiu+k3I9ugYf8JL5AjJ3uaJyfhKXjiH9NBvSM9+PyRbOWgNpxbNBcok5RzDY74XVCDd+khHxDyEiwrIFRxa9ELOe3y17CWvTHhm2mBJS3lZA6b+zZ/ByQaBsX+F6402kqxwxN4hv7Id56fP47mhy1CqeknKwYZ4JOzNVsduTbAQv9Z/nCZQtQEj3FRbob8bemNoAWLYAJPALKegNmzK71M9DECE/TQmtrPh1aKTFAbYQ0gUzDL4yi7Y39pZSs7yPKxTzo1aTrVzgfV6oi0MPpC30SzLDICaPqL1lVPbYG5pneagvOTNqNZkao/d5oS72c1zWG/3SBDbEoVvnNoJBfsfMva7oJy0vfZ8/1GeXfVzn5eb4kPe50xH93+kkNDLkl2bQsCCCXwe0jaXF3BD62ryPrRP3o1bzubvT+9zpiLwUj0hvp1+SCMMCDL8dwycjS2JvyGUH2k4DLOGIaI5azQnnh5hgxsVeN8bi4L+4FWALo0fJ4GsA8iZldUNL0GPWxb4W+PhVdAkXPkqKflJtEVdwprcfpzuSYrMFAOxDoBuf+YkYfE0Q6KqvcFxS4u/deYgVqb0G+25Xxqzn1+1voKE34hlnRET9B4Db6Scj0FUhiILG6If9K1A7mBUolsVfu57FneMaqOQGOXxQOQiqijaIC+/FZAtdvwTRej5ILoiahWDVOXjiFX68evc5XBz5SlQNvtO7Erae1dD1CayuMETVCZPbYV2fgGishWg6G5Ut1NEKxdnjobJYsOqcoq3lgmNZztofAvBbsk2wQO1QFlQkYUVSF8QUruDYJTVeu/tNnOj5mlemHRQwN2kwuMSN8ZSpzWTGDiXWvGGE4d7DTRDduQoa6QcWWACFMrISSYK4XAvFuePARMhX6EqV7eifFACQlZNnAVAQWIJBuGRfiNqhLCSRG2b1CDQiWNl9lx7v9T+F394pRIvjS0H5qlHCgvMa6LsUcOoZYyYOPpVkgumGCl89pUP2SR3Uo8Gjhno7IVo/8ST0JkAdPDnSmB2irRGKj/4But4YYmw/KAd6u72loSqmoOjghBb3nDMcFDXMAyfpow6KelsstP6sefouOM42qKXG9noO4HMuQMQVM2dQYvH11dsBSXpTORH1z4xJicNzNGYqn0x7p9mrl+qclpz8JAZvmBHLEoQg8cr7bx2p8qZ9M3U67WECpvV6+kxCQLdOpz3sK/OLNrY2fjq2bGVeDxg/SKxpCULQvvff/sunvqKg/x1mps07S/8zW+4Bxwsiqqs6UfatwEvVQREhImJm3k2grsSZN70QqIuZd4e6UR4yJFZjK78Noh0EOEPlzyU81+RoR7ib5GFPHNqb6zssufl3GTyn5wNBoqS6suxkuPxHHrlcb66/tCx3bQpC7BPmBIRD1ZWv/+FRRSJGhVO58AABYXtwtkLAyVQuPBCpXMQOsNmKJkwo3AHCofiYlgAIh6ZyU9xTVAaey9PS0dl6f5gAJ5HYW1VZNuV9zZNPZuQ2VF1ZVsfgfCKS1dB0QkR1DM6X6zzw5LO5Jx9OPvl0Nh5KAvm//Hg6HLP98/n/AbIVpUXHBEvwAAAAAElFTkSuQmCC';
  16. const globalParser = new DOMParser();
  17. var lastTitle = undefined;
  18. var lastYear = undefined;
  19. function extractData() {
  20. const titleElement = document.querySelector('h1[data-testid="metadata-title"]');
  21. const yearElement = document.querySelector('span[data-testid="metadata-line1"]');
  22.  
  23. if (titleElement) {
  24. const title = titleElement.textContent.trim() || titleElement.innerText.trim();
  25. if (title !== lastTitle) {
  26. lastTitle = title;
  27. console.log('The title is:', lastTitle);
  28. }
  29. } else {
  30. lastTitle = ''; // Reset if no title is found
  31. }
  32.  
  33. if (yearElement) {
  34. const text = yearElement.textContent.trim() || yearElement.innerText.trim();
  35. const match = text.match(/\b\d{4}\b/);
  36. if (match && match[0] !== lastYear) {
  37. lastYear = match[0];
  38. console.log('The year is:', lastYear);
  39. }
  40. } else {
  41. lastYear = ''; // Reset if no year is found
  42. }
  43. }
  44.  
  45. function checkLink(url) {
  46. return new Promise((resolve, reject) => {
  47. GM.xmlHttpRequest({
  48. method: 'HEAD',
  49. url: url,
  50. onload: function(response) {
  51. if (response.status >= 200 && response.status < 300) {
  52. resolve({url: url, status: response.status, accessible: true});
  53. } else {
  54. resolve({url: url, status: response.status, accessible: false});
  55. }
  56. },
  57. onerror: function() {
  58. reject(new Error(url + ' could not be reached or is blocked by CORS policy.'));
  59. }
  60. });
  61. });
  62. }
  63.  
  64. function updateOrCreateLetterboxdIcon(link, rating) {
  65. let metadataElement = document.querySelector('div[data-testid="metadata-ratings"]');
  66. if (!metadataElement) {
  67. metadataElement = document.querySelector('div[data-testid="metadata-children"]');
  68. }
  69.  
  70. const existingContainer = document.querySelector('.letterboxd-container');
  71.  
  72. if (existingContainer) {
  73. existingContainer.querySelector('a').href = link;
  74. const ratingElement = existingContainer.querySelector('.letterboxd-rating');
  75. if (ratingElement) {
  76. ratingElement.textContent = rating;
  77. }
  78. } else if (metadataElement) {
  79. const container = document.createElement('div');
  80. container.classList.add('letterboxd-container');
  81. container.style.cssText = 'display: flex; align-items: center; gap: 8px;';
  82.  
  83. const icon = document.createElement('img');
  84. icon.src = letterboxdImg;
  85. icon.alt = 'Letterboxd Icon';
  86. icon.style.cssText = 'width: 24px; height: 24px; cursor: pointer;';
  87.  
  88. const ratingText = document.createElement('span');
  89. ratingText.classList.add('letterboxd-rating');
  90. ratingText.textContent = rating; // ? rating : "Director's Page"; That was neat for director's pages, but shows up with films that don't have ratings
  91. ratingText.style.cssText = 'font-size: 14px;'; // Style as needed
  92.  
  93. const linkElement = document.createElement('a');
  94. linkElement.href = link;
  95. linkElement.appendChild(icon);
  96.  
  97. container.appendChild(linkElement);
  98. container.appendChild(ratingText);
  99. metadataElement.insertAdjacentElement('afterend', container);
  100. }
  101. }
  102.  
  103. function buildDefaultLetterboxdUrl(title, year) {
  104. const normalizedTitle = title.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
  105. const titleSlug = normalizedTitle.trim().toLowerCase()
  106. .replace(/&/g, 'and')
  107. .replace(/[^\w\s-]/g, '')
  108. .replace(/\s+/g, '-');
  109. const letterboxdBaseUrl = 'https://letterboxd.com/film/';
  110. return `${letterboxdBaseUrl}${titleSlug}-${year}/`;
  111. }
  112.  
  113. function removeYearFromUrl(url) {
  114. const yearPattern = /-\d{4}(?=\/$)/;
  115. return url.replace(yearPattern, '');
  116. }
  117.  
  118. function replaceFilmWithDirector(url) {
  119. return url.replace('film','director');
  120. }
  121.  
  122. function buildLetterboxdUrl(title, year) {
  123. let defaultUrl = buildDefaultLetterboxdUrl(title, year);
  124. return checkLink(defaultUrl).then(result => {
  125. if (result.accessible) {
  126. console.log(result.url, 'is accessible, status:', result.status);
  127. return result.url;
  128. } else {
  129. console.log(result.url, 'is not accessible, status:', result.status);
  130. let yearRemovedUrl = removeYearFromUrl(result.url);
  131. console.log('Trying URL without year:', yearRemovedUrl);
  132. return checkLink(yearRemovedUrl).then(yearRemovedResult => {
  133. if (yearRemovedResult.accessible) {
  134. console.log(yearRemovedUrl, 'is accessible, status:', yearRemovedResult.status);
  135. return yearRemovedUrl;
  136. } else {
  137. console.log(yearRemovedUrl, 'is not accessible, status:', yearRemovedResult.status);
  138. let directorUrl = replaceFilmWithDirector(yearRemovedUrl);
  139. console.log('Trying director URL:', directorUrl);
  140. return checkLink(directorUrl).then(result =>{
  141. if (result.accessible){
  142. return directorUrl;
  143. }else{
  144. console.log(result.url, 'is not accessible, status:', result.status);
  145. }
  146. });
  147. }
  148. });
  149. }
  150. }).catch(error => {
  151. console.error('Error after checking both film and year:', error.message);
  152. let newUrl = removeYearFromUrl(defaultUrl);
  153. return newUrl;
  154. });
  155. }
  156.  
  157. function fetchLetterboxdPage(url) {
  158. return new Promise((resolve, reject) => {
  159. GM.xmlHttpRequest({
  160. method: 'GET',
  161. url: url,
  162. onload: function(response) {
  163. if (response.status >= 200 && response.status < 300) {
  164. resolve(response.responseText);
  165. } else {
  166. reject(new Error('Failed to load Letterboxd page'));
  167. }
  168. },
  169. onerror: function() {
  170. reject(new Error('Network error while fetching Letterboxd page'));
  171. }
  172. });
  173. });
  174. }
  175.  
  176. function roundToOneDecimal(numberString) {
  177. const number = parseFloat(numberString);
  178. return isNaN(number) ? null : (Math.round(number * 10) / 10).toFixed(1);
  179. }
  180.  
  181. function extractRating(html) {
  182. const doc = globalParser.parseFromString(html, 'text/html');
  183. const ratingElement = doc.querySelector('meta[name="twitter:data2"]');
  184.  
  185. if (ratingElement && ratingElement.content) {
  186. const match = ratingElement.getAttribute('content').match(/\b\d+\.\d{1,2}\b/);
  187. if (match) {
  188. return roundToOneDecimal(match[0]);
  189. }
  190. } else {
  191. console.log('Rating element not found.');
  192. return null;
  193. }
  194. }
  195.  
  196. function extractYearFromMeta(doc) {
  197. const metaTag = doc.querySelector('meta[property="og:title"]');
  198. if (metaTag) {
  199. const content = metaTag.getAttribute('content');
  200. const yearMatch = content.match(/\b\d{4}\b/);
  201. if (yearMatch) {
  202. return yearMatch[0];
  203. } else {
  204. console.log('Year not found in the html');
  205. return null;
  206. }
  207. } else {
  208. console.log('Meta tag not found in the html');
  209. return null;
  210. }
  211. }
  212.  
  213. if(document.readyState === 'complete' || document.readyState === 'loaded' || document.readyState === 'interactive') {
  214. main();
  215. } else {
  216. document.addEventListener('DOMContentLoaded', main);
  217. }
  218.  
  219. function main() {
  220. var lastProcessedTitle = undefined;
  221. var lastProcessedYear = undefined;
  222.  
  223.  
  224. function observerCallback(mutationsList, observer) {
  225. const isAlbumPage = document.querySelector('[class^="AlbumDisc"]');
  226. // My attempt to remove series that aren't miniseries, because letterboxd only have those.
  227. const isFullSeries = document.querySelector('[title*="Season 4"], [title*="Season 5"], [title*="Season 6"]');
  228. const hasOneSeason = document.querySelector('[title*="Season 1"]');
  229. if (isAlbumPage || isFullSeries) {
  230. //console.log('Detected an album or series page, not proceeding with Letterboxd icon creation.');
  231. return;
  232. }
  233. extractData();
  234. if (lastTitle !== lastProcessedTitle || lastYear !== lastProcessedYear) {
  235. lastProcessedTitle = lastTitle;
  236. lastProcessedYear = lastYear;
  237.  
  238. if (lastTitle && lastYear) {
  239. buildLetterboxdUrl(lastTitle, lastYear).then(url => {
  240. if (!url){
  241. return;
  242. }
  243. fetchLetterboxdPage(url).then(html => {
  244. //console.log(html);
  245. const doc = globalParser.parseFromString(html, "text/html");
  246. const yearInHtml = extractYearFromMeta(doc);
  247. if (yearInHtml !== lastYear){
  248. console.log(`Url doesn't point to the right movie. Icon creation aborted.`);
  249. return;
  250. }
  251. if (hasOneSeason){
  252. const isATvShow = doc.querySelector('a[href*="themoviedb.org/tv/"]');
  253. if (!isATvShow) {
  254. console.log(`It got a movie or director on a tv show page. Icon creation aborted.`);
  255. return;
  256. }
  257. }
  258. const rating = extractRating(html);
  259. updateOrCreateLetterboxdIcon(url, rating);
  260. }).catch(error => {
  261. console.error('Error fetching or parsing Letterboxd page:', error);
  262. });
  263. }).catch(error => {
  264. console.error('Error building Letterboxd URL:', error);
  265. });
  266. }
  267. }
  268. }
  269.  
  270. const observer = new MutationObserver(observerCallback);
  271. observer.observe(document.body, {
  272. childList: true,
  273. characterData: true,
  274. subtree: true
  275. });
  276. }
  277. })();