Plex Letterboxd link and rating

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

当前为 2023-12-30 提交的版本,查看 最新版本

  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 1.4
  12. // ==/UserScript==
  13. (function() {
  14. 'use strict';
  15. const letterboxdImg = 'https://www.google.com/s2/favicons?sz=64&domain=letterboxd.com';
  16. var lastTitle = undefined;
  17. var lastYear = undefined;
  18. //Function Definitions
  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.  
  46. function checkLink(url) {
  47. return new Promise((resolve, reject) => {
  48. GM.xmlHttpRequest({
  49. method: 'HEAD',
  50. url: url,
  51. onload: function(response) {
  52. if (response.status >= 200 && response.status < 300) {
  53. resolve({url: url, status: response.status, accessible: true});
  54. } else {
  55. resolve({url: url, status: response.status, accessible: false});
  56. }
  57. },
  58. onerror: function() {
  59. reject(new Error(url + ' could not be reached or is blocked by CORS policy.'));
  60. }
  61. });
  62. });
  63. }
  64.  
  65. function updateOrCreateLetterboxdIcon(link, rating) {
  66. const existingContainer = document.querySelector('.letterboxd-container');
  67. const metadataElement = document.querySelector('div[data-testid="metadata-ratings"]');
  68.  
  69. if (existingContainer) {
  70. existingContainer.querySelector('a').href = link;
  71. const ratingElement = existingContainer.querySelector('.letterboxd-rating');
  72. if (ratingElement) {
  73. ratingElement.textContent = rating;
  74. }
  75. } else if (metadataElement) {
  76. const container = document.createElement('div');
  77. container.classList.add('letterboxd-container');
  78. container.style.cssText = 'display: flex; align-items: center; gap: 8px;';
  79.  
  80. const icon = document.createElement('img');
  81. icon.src = letterboxdImg;
  82. icon.alt = 'Letterboxd Icon';
  83. icon.style.cssText = 'width: 24px; height: 24px; cursor: pointer;';
  84.  
  85. const ratingText = document.createElement('span');
  86. ratingText.classList.add('letterboxd-rating');
  87. ratingText.textContent = rating;
  88. ratingText.style.cssText = 'font-size: 14px;';
  89.  
  90. const linkElement = document.createElement('a');
  91. linkElement.href = link;
  92. // linkElement.target = '_blank'; // Uncomment if you want to open in a new tab
  93. linkElement.appendChild(icon);
  94. container.appendChild(linkElement);
  95. container.appendChild(ratingText);
  96. metadataElement.insertAdjacentElement('afterend', container);
  97. }
  98. }
  99.  
  100.  
  101. function buildDefaultLetterboxdUrl(title, year) {
  102. const titleSlug = title.trim().toLowerCase()
  103. .replace(/&/g, 'and')
  104. .replace(/[^\w\s-]/g, '')
  105. .replace(/\s+/g, '-');
  106.  
  107. const letterboxdBaseUrl = 'https://letterboxd.com/film/';
  108. return `${letterboxdBaseUrl}${titleSlug}-${year}/`;
  109. }
  110.  
  111.  
  112. function removeYearFromUrl(url) {
  113. const yearPattern = /-\d{4}(?=\/$)/;
  114. return url.replace(yearPattern, '');
  115. }
  116.  
  117. function replaceFilmWithDirector(url) {
  118. return url.replace('film','director');
  119. }
  120.  
  121. function buildLetterboxdUrl(title, year) {
  122. let defaultUrl = buildDefaultLetterboxdUrl(title, year);
  123. return checkLink(defaultUrl).then(result => {
  124. if (result.accessible) {
  125. console.log(result.url, 'is accessible, status:', result.status);
  126. return result.url;
  127. } else {
  128. console.log(result.url, 'is not accessible, status:', result.status);
  129. let yearRemovedUrl = removeYearFromUrl(result.url);
  130. console.log('Trying URL without year:', yearRemovedUrl);
  131. return checkLink(yearRemovedUrl).then(yearRemovedResult => {
  132. if (yearRemovedResult.accessible) {
  133. console.log(yearRemovedUrl, 'is accessible, status:', yearRemovedResult.status);
  134. return yearRemovedUrl;
  135. } else {
  136. console.log(yearRemovedUrl, 'is not accessible, status:', yearRemovedResult.status);
  137. let directorUrl = replaceFilmWithDirector(yearRemovedUrl);
  138. console.log('Trying director URL:', directorUrl);
  139. return directorUrl;
  140. }
  141. });
  142. }
  143. }).catch(error => {
  144. console.error('Error after checking both film and year:', error.message);
  145. let newUrl = removeYearFromUrl(defaultUrl);
  146. return newUrl;
  147. });
  148. }
  149.  
  150. function fetchLetterboxdPage(url) {
  151. return new Promise((resolve, reject) => {
  152. GM.xmlHttpRequest({
  153. method: 'GET',
  154. url: url,
  155. onload: function(response) {
  156. if (response.status >= 200 && response.status < 300) {
  157. resolve(response.responseText);
  158. } else {
  159. reject(new Error('Failed to load Letterboxd page'));
  160. }
  161. },
  162. onerror: function() {
  163. reject(new Error('Network error while fetching Letterboxd page'));
  164. }
  165. });
  166. });
  167. }
  168.  
  169. function roundToOneDecimal(numberString) {
  170. const number = parseFloat(numberString);
  171. return isNaN(number) ? null : (Math.round(number * 10) / 10).toFixed(1);
  172. }
  173.  
  174. function extractRating(html) {
  175. const parser = new DOMParser();
  176. const doc = parser.parseFromString(html, 'text/html');
  177. const ratingElement = doc.querySelector('meta[name="twitter:data2"]');
  178.  
  179. if (ratingElement && ratingElement.content) {
  180. const match = ratingElement.getAttribute('content').match(/\b\d+\.\d{1,2}\b/);
  181. if (match) {
  182. return roundToOneDecimal(match[0]);
  183. }
  184. } else {
  185. console.log('Rating element not found.');
  186. return null;
  187. }
  188. }
  189.  
  190. // Main
  191. if(document.readyState === 'complete' || document.readyState === 'loaded' || document.readyState === 'interactive') {
  192. main();
  193. } else {
  194. document.addEventListener('DOMContentLoaded', main);
  195. }
  196.  
  197. function main() {
  198. var lastProcessedTitle = undefined;
  199. var lastProcessedYear = undefined;
  200.  
  201. function observerCallback(mutationsList, observer) {
  202. extractData();
  203. if (lastTitle !== lastProcessedTitle || lastYear !== lastProcessedYear) {
  204. lastProcessedTitle = lastTitle;
  205. lastProcessedYear = lastYear;
  206.  
  207. if (lastTitle && lastYear) {
  208. buildLetterboxdUrl(lastTitle, lastYear).then(url => {
  209. fetchLetterboxdPage(url).then(html => {
  210. const rating = extractRating(html);
  211. updateOrCreateLetterboxdIcon(url, rating);
  212. }).catch(error => {
  213. console.error('Error fetching or parsing Letterboxd page:', error);
  214. });
  215. }).catch(error => {
  216. console.error('Error building Letterboxd URL:', error);
  217. });
  218. }
  219. }
  220. }
  221.  
  222. const observer = new MutationObserver(observerCallback);
  223. observer.observe(document.body, {
  224. childList: true,
  225. characterData: true,
  226. subtree: true
  227. });
  228. }
  229. })();