Plex Letterboxd link and rating

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

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

  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=letterboxd.com
  8. // @license MIT
  9. // @grant GM_xmlhttpRequest
  10. // @connect letterboxd.com
  11. // @version 2.7.0
  12. // ==/UserScript==
  13. //Edge cases:
  14. //Vietnam: A Television History is a tv show logged as movie in Tmdb so doesn't get an icon
  15. //Films that have both same year and name and one of them has no directors like Cargo 2006 and Cargo 2006 by Clive Gordon
  16. //Directors that are very unknown don't get the icon, don't know why
  17. //Also Clive Gordon's director's page makes the script stop after getting the title for unknown reasons
  18. //The Shining has a bug on letterboxd where the-shining-1980 links to the-shining-1997
  19. //I did a fall back to remove the year from the url for a last try for stupid cases like The Shining, which may cause problems elsewhere, but probably not.
  20. //Letterboxd api will make all this obsolete so its not really worth the time.
  21. (function() {
  22. 'use strict';
  23. //const letterboxdImg = 'data:image/png;base64,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';
  24. const letterboxdImg = 'https://www.google.com/s2/favicons?sz=64&domain=letterboxd.com';
  25. const globalParser = new DOMParser();
  26. var lastTitle = undefined;
  27. var lastYear = undefined;
  28. var lastDirector = undefined;
  29. function extractTitleAndYear() {
  30. const titleElement = document.querySelector('h1[data-testid="metadata-title"]');
  31. const yearElement = document.querySelector('span[data-testid="metadata-line1"]');
  32.  
  33. if (titleElement) {
  34. const title = titleElement.textContent.trim() || titleElement.innerText.trim();
  35. if (title !== lastTitle) {
  36. lastTitle = title;
  37. console.log('The title is:', lastTitle);
  38. }
  39. } else {
  40. lastTitle = ''; // Reset if no title is found
  41. }
  42.  
  43. if (yearElement) {
  44. const text = yearElement.textContent.trim() || yearElement.innerText.trim();
  45. const match = text.match(/\b\d{4}\b/);
  46. if (match && match[0] !== lastYear) {
  47. lastYear = match[0];
  48. console.log('The year is:', lastYear);
  49. }
  50. } else {
  51. lastYear = ''; // Reset if no year is found
  52. }
  53. }
  54.  
  55. function extractDirectorFromPage() {
  56. const directedByText = 'Directed by';
  57. const spans = Array.from(document.querySelectorAll('span'));
  58.  
  59. const directorSpan = spans.find(span => span.textContent.includes(directedByText));
  60. if (directorSpan) {
  61. const directorLink = directorSpan.parentElement.querySelector('a');
  62. if (directorLink) {
  63. const directorName = directorLink.textContent.trim();
  64. if (directorName && directorName !== lastDirector) {
  65. lastDirector = directorName;
  66. console.log('Director in Plex: ', lastDirector);
  67. }
  68. }
  69. } else {
  70. if (lastDirector !== undefined) {
  71. lastDirector = undefined;
  72. console.log('The director has been reset.');
  73. }
  74. }
  75. }
  76.  
  77. function checkLink(url) {
  78. return new Promise((resolve, reject) => {
  79. GM.xmlHttpRequest({
  80. method: 'HEAD',
  81. url: url,
  82. onload: function(response) {
  83. if (response.status >= 200 && response.status < 300) {
  84. resolve({url: url, status: response.status, accessible: true});
  85. } else {
  86. resolve({url: url, status: response.status, accessible: false});
  87. }
  88. },
  89. onerror: function() {
  90. reject(new Error(url + ' could not be reached or is blocked by CORS policy.'));
  91. }
  92. });
  93. });
  94. }
  95.  
  96. function updateOrCreateLetterboxdIcon(link, rating) {
  97. let metadataElement = document.querySelector('div[data-testid="metadata-ratings"]');
  98. if (!metadataElement) {
  99. metadataElement = document.querySelector('div[data-testid="metadata-children"]');
  100. }
  101.  
  102. const existingContainer = document.querySelector('.letterboxd-container');
  103.  
  104. if (existingContainer) {
  105. existingContainer.querySelector('a').href = link;
  106. const ratingElement = existingContainer.querySelector('.letterboxd-rating');
  107. if (ratingElement) {
  108. ratingElement.textContent = rating;
  109. }
  110. } else if (metadataElement) {
  111. const container = document.createElement('div');
  112. container.classList.add('letterboxd-container');
  113. container.style.cssText = 'display: flex; align-items: center; gap: 8px;';
  114.  
  115. const icon = document.createElement('img');
  116. icon.src = letterboxdImg;
  117. icon.alt = 'Letterboxd Icon';
  118. icon.style.cssText = 'width: 24px; height: 24px; cursor: pointer;';
  119.  
  120. const ratingText = document.createElement('span');
  121. ratingText.classList.add('letterboxd-rating');
  122. ratingText.textContent = rating;
  123. ratingText.style.cssText = 'font-size: 14px;'; // Style as needed
  124.  
  125. const linkElement = document.createElement('a');
  126. linkElement.href = link;
  127. linkElement.appendChild(icon);
  128.  
  129. container.appendChild(linkElement);
  130. container.appendChild(ratingText);
  131. metadataElement.insertAdjacentElement('afterend', container);
  132. }
  133. }
  134.  
  135. function buildDefaultLetterboxdUrl(title, year) {
  136. const normalizedTitle = title.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
  137. const titleSlug = normalizedTitle.trim().toLowerCase()
  138. .replace(/&/g, 'and')
  139. .replace(/-/g, ' ')
  140. .replace(/[^\w\s]/g, '')
  141. .replace(/\s+/g, '-');
  142. const letterboxdBaseUrl = 'https://letterboxd.com/film/';
  143. return `${letterboxdBaseUrl}${titleSlug}-${year}/`;
  144. }
  145.  
  146. function removeYearFromUrl(url) {
  147. const yearPattern = /-\d{4}(?=\/$)/;
  148. return url.replace(yearPattern, '');
  149. }
  150.  
  151. function replaceFilmWithDirector(url) {
  152. return url.replace('film','director');
  153. }
  154.  
  155. function buildLetterboxdUrl(title, year) {
  156. let defaultUrl = buildDefaultLetterboxdUrl(title, year);
  157. return checkLink(defaultUrl).then(result => {
  158. if (result.accessible) {
  159. console.log(result.url, 'is accessible, status:', result.status);
  160. return result.url;
  161. } else {
  162. console.log(result.url, 'is not accessible, status:', result.status);
  163. let yearRemovedUrl = removeYearFromUrl(result.url);
  164. console.log('Trying URL without year:', yearRemovedUrl);
  165. return checkLink(yearRemovedUrl).then(yearRemovedResult => {
  166. if (yearRemovedResult.accessible) {
  167. console.log(yearRemovedUrl, 'is accessible, status:', yearRemovedResult.status);
  168. return yearRemovedUrl;
  169. } else {
  170. console.log(yearRemovedUrl, 'is not accessible, status:', yearRemovedResult.status);
  171. let directorUrl = replaceFilmWithDirector(yearRemovedUrl);
  172. console.log('Trying director URL:', directorUrl);
  173. return checkLink(directorUrl).then(result =>{
  174. if (result.accessible){
  175. return directorUrl;
  176. }else{
  177. console.log(result.url, 'is not accessible, status:', result.status);
  178. }
  179. });
  180. }
  181. });
  182. }
  183. }).catch(error => {
  184. console.error('Error after checking both film and year:', error.message);
  185. let newUrl = removeYearFromUrl(defaultUrl);
  186. return newUrl;
  187. });
  188. }
  189.  
  190. function fetchLetterboxdPage(url) {
  191. return new Promise((resolve, reject) => {
  192. GM.xmlHttpRequest({
  193. method: 'GET',
  194. url: url,
  195. onload: function(response) {
  196. if (response.status >= 200 && response.status < 300) {
  197. resolve(response.responseText);
  198. } else {
  199. reject(new Error('Failed to load Letterboxd page'));
  200. }
  201. },
  202. onerror: function() {
  203. reject(new Error('Network error while fetching Letterboxd page'));
  204. }
  205. });
  206. });
  207. }
  208.  
  209. function roundToOneDecimal(numberString) {
  210. const number = parseFloat(numberString);
  211. return isNaN(number) ? null : (Math.round(number * 10) / 10).toFixed(1);
  212. }
  213.  
  214. function extractRating(doc) {
  215. const ratingElement = doc.querySelector('meta[name="twitter:data2"]');
  216. if (ratingElement && ratingElement.content) {
  217. const match = ratingElement.getAttribute('content').match(/\b\d+\.\d{1,2}\b/);
  218. if (match) {
  219. return roundToOneDecimal(match[0]);
  220. }
  221. } else {
  222. console.log('Rating element not found.');
  223. return null;
  224. }
  225. }
  226.  
  227. function extractYearFromMeta(doc) {
  228. const metaTag = doc.querySelector('meta[property="og:title"]');
  229. if (metaTag) {
  230. const content = metaTag.getAttribute('content');
  231. const yearMatch = content.match(/\b\d{4}\b/);
  232. if (yearMatch) {
  233. return yearMatch[0];
  234. } else {
  235. console.log('Year not found in the html');
  236. return null;
  237. }
  238. } else {
  239. console.log('Meta tag not found in the html');
  240. return null;
  241. }
  242. }
  243.  
  244. function extractDirectorFromMeta(doc) {
  245. const directorMetaTag = doc.querySelector('meta[name="twitter:data1"]');
  246. return directorMetaTag ? directorMetaTag.content : null;
  247. }
  248.  
  249. function subtractYearFromUrl(url, lastYear) {
  250. const yearPattern = /-(\d{4})\/$/;
  251. const match = url.match(yearPattern);
  252.  
  253. if (match) {
  254. const year = parseInt(match[1], 10) - 1;
  255. return url.replace(yearPattern, `-${year}/`);
  256. } else {
  257. const previousYear = parseInt(lastYear, 10) - 1;
  258. return url.replace(/\/$/, `-${previousYear}/`);
  259. }
  260. }
  261.  
  262. async function processLetterboxdUrl(initialUrl) {
  263. let url = initialUrl;
  264. let shouldContinue = true;
  265.  
  266. const hasOneSeason = document.querySelector('[title*="Season 1"]');
  267. while (shouldContinue) {
  268. try {
  269. const html = await fetchLetterboxdPage(url);
  270. const doc = globalParser.parseFromString(html, "text/html");
  271.  
  272. if (hasOneSeason && !doc.querySelector('a[href*="themoviedb.org/tv/"]')) {
  273. console.log(`Plex got a tv show but Letterboxd is on a movie or director page.`);
  274. console.log(`Icon creation aborted.`);
  275. break;
  276. }
  277.  
  278. if (url.startsWith('https://letterboxd.com/director')) {
  279. updateOrCreateLetterboxdIcon(url, 'Letterboxd');
  280. break;
  281. } else {
  282. const yearInHtml = extractYearFromMeta(doc);
  283. console.log('The year in the html is : ' + yearInHtml);
  284. if (yearInHtml != lastYear) {
  285. const directorInHtml = extractDirectorFromMeta(doc);
  286. console.log('Director in Html: ' + directorInHtml);
  287.  
  288. if (!directorInHtml.includes(lastDirector)) {
  289. let subtractedYearUrl = subtractYearFromUrl(url, lastYear);
  290. console.log('Trying substracted year url: ' + subtractedYearUrl);
  291. let result = await checkLink(subtractedYearUrl);
  292.  
  293. if (result.accessible) {
  294. console.log(subtractedYearUrl, 'Url with substracted year is accessible, status:', result.status);
  295. const newHtml = await fetchLetterboxdPage(subtractedYearUrl);
  296. const newDoc = globalParser.parseFromString(newHtml, "text/html");
  297. const newDirectorInHtml = extractDirectorFromMeta(newDoc);
  298.  
  299. if (newDirectorInHtml.includes(lastDirector)) {
  300. url = subtractedYearUrl;
  301. continue;
  302. } else {
  303. console.log(`Director in Plex [${lastDirector}] doesn't match director in html [${newDirectorInHtml}]`);
  304. }
  305. } else {
  306. console.log('Url with substracted year is inaccessible');
  307. }
  308.  
  309. // Fallback to URL without year
  310. let urlWithoutYear = removeYearFromUrl(url);
  311. result = await checkLink(urlWithoutYear);
  312. if (result.accessible) {
  313. console.log(result.url, 'is accessible, status:', result.status);
  314. console.log('Going back to url without year as fallback');
  315. url = urlWithoutYear; // Update URL to the one without year
  316. continue; // Check again with the updated URL
  317. } else {
  318. console.log(result.url, 'is not accessible, status:', result.status);
  319. console.log(`Icon creation aborted`);
  320. break;
  321. }
  322. } else {
  323. console.log('Movie has different year but same director and name, probably due to differing metadata. Icon created');
  324. const rating = extractRating(doc);
  325. updateOrCreateLetterboxdIcon(url, rating);
  326. break;
  327. }
  328. } else {
  329. const rating = extractRating(doc);
  330. updateOrCreateLetterboxdIcon(url, rating);
  331. break;
  332. }
  333. }
  334. } catch (error) {
  335. console.error('Error fetching or parsing Letterboxd page:', error);
  336. break;
  337. }
  338. }
  339. }
  340.  
  341. if(document.readyState === 'complete' || document.readyState === 'loaded' || document.readyState === 'interactive') {
  342. main();
  343. } else {
  344. document.addEventListener('DOMContentLoaded', main);
  345. }
  346.  
  347. function main() {
  348. var lastProcessedTitle = undefined;
  349. var lastProcessedYear = undefined;
  350. var lastProcessedDirector = undefined;
  351.  
  352. function observerCallback(mutationsList, observer) {
  353. const isAlbumPage = document.querySelector('[class^="AlbumDisc"]');
  354. const isFullSeries = document.querySelector('[title*="Season 4"], [title*="Season 5"], [title*="Season 6"]');
  355.  
  356. if (isAlbumPage || isFullSeries) {
  357. return;
  358. }
  359. extractTitleAndYear();
  360. extractDirectorFromPage();
  361.  
  362. if (lastTitle !== lastProcessedTitle || lastYear !== lastProcessedYear || lastDirector !== lastProcessedDirector ) {
  363. lastProcessedTitle = lastTitle;
  364. lastProcessedYear = lastYear;
  365. lastProcessedDirector = lastDirector;
  366.  
  367. if (lastTitle && lastYear ) {
  368. buildLetterboxdUrl(lastTitle, lastYear).then(url => {
  369. if (!url){
  370. return;
  371. }
  372. processLetterboxdUrl(url, lastYear, lastDirector);
  373.  
  374. }).catch(error => {
  375. console.error('Error building Letterboxd URL:', error);
  376. });
  377. }
  378. }
  379. }
  380.  
  381.  
  382. const observer = new MutationObserver(observerCallback);
  383. observer.observe(document.body, {
  384. childList: true,
  385. characterData: true,
  386. subtree: true
  387. });
  388. }
  389. })();