Plex GUID Grabber

Grab the GUID of a Plex entry on demand

  1. // ==UserScript==
  2. // @name Plex GUID Grabber
  3. // @namespace @soitora/plex-guid-grabber
  4. // @description Grab the GUID of a Plex entry on demand
  5. // @version 3.5.0
  6. // @license MPL-2.0
  7. // @icon https://app.plex.tv/desktop/favicon.ico
  8. // @homepageURL https://soitora.com/Plex-GUID-Grabber/
  9. // @include *:32400/*
  10. // @include *://plex.*/*
  11. // @include https://app.plex.tv/*
  12. // @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js
  13. // @require https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js
  14. // @require https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.11/clipboard.min.js
  15. // @require https://cdn.jsdelivr.net/npm/sweetalert2@11
  16. // @grant GM_getValue
  17. // @grant GM_setValue
  18. // @grant GM_addStyle
  19. // @grant GM_xmlhttpRequest
  20. // @grant GM_getResourceText
  21. // @grant GM_setClipboard
  22. // @run-at document-end
  23. // ==/UserScript==
  24.  
  25. GM_addStyle(`button[id$="-guid-button"],
  26. button[id$="-yaml-button"] {
  27. margin-right: 4px;
  28. }
  29.  
  30. button[id$="-guid-button"]:not([id="imdb-guid-button"]):hover img,
  31. button[id$="-yaml-button"]:not([id="imdb-yaml-button"]):hover img {
  32. filter: invert(100%) grayscale(100%) contrast(120%);
  33. }
  34.  
  35. button[id="imdb-guid-button"]:hover img,
  36. button[id="imdb-yaml-button"]:hover img {
  37. filter: grayscale(100%) contrast(120%);
  38. }
  39.  
  40. button[id="imdb-guid-button"] img,
  41. button[id="imdb-yaml-button"] img {
  42. width: 30px !important;
  43. height: 30px !important;
  44. }
  45.  
  46. .pgg-toast-container {
  47. min-width: 400px !important;
  48. max-width: 800px !important;
  49. }
  50.  
  51. .pgg-toast-yaml {
  52. white-space: pre-wrap;
  53. font-family: monospace;
  54. }
  55. `);
  56.  
  57. // Initialize GM values if they don't exist
  58. function initializeGMValues() {
  59. if (GM_getValue("USE_SOCIAL_BUTTONS") === undefined) {
  60. GM_setValue("USE_SOCIAL_BUTTONS", true);
  61. console.log(LOG_PREFIX, LOG_STYLE, "Created USE_SOCIAL_BUTTONS storage");
  62. }
  63.  
  64. if (GM_getValue("SOCIAL_BUTTON_SEPARATION") === undefined) {
  65. GM_setValue("SOCIAL_BUTTON_SEPARATION", true);
  66. console.log(LOG_PREFIX, LOG_STYLE, "Created SOCIAL_BUTTON_SEPARATION storage");
  67. }
  68.  
  69. if (GM_getValue("USE_PAS") === undefined) {
  70. GM_setValue("USE_PAS", false);
  71. console.log(LOG_PREFIX, LOG_STYLE, "Created USE_PAS storage");
  72. }
  73.  
  74. if (GM_getValue("TMDB_API_READ_ACCESS_TOKEN") === undefined) {
  75. GM_setValue("TMDB_API_READ_ACCESS_TOKEN", "");
  76. console.log(LOG_PREFIX, LOG_STYLE, "Created TMDB_API_READ_ACCESS_TOKEN storage");
  77. }
  78.  
  79. if (GM_getValue("TMDB_LANGUAGE") === undefined) {
  80. GM_setValue("TMDB_LANGUAGE", "en-US");
  81. console.log(LOG_PREFIX, LOG_STYLE, "Created TMDB_LANGUAGE storage");
  82. }
  83.  
  84. if (GM_getValue("TVDB_API_KEY") === undefined) {
  85. GM_setValue("TVDB_API_KEY", "");
  86. console.log(LOG_PREFIX, LOG_STYLE, "Created TVDB_API_KEY storage");
  87. }
  88.  
  89. if (GM_getValue("TVDB_SUBSCRIBER_PIN") === undefined) {
  90. GM_setValue("TVDB_SUBSCRIBER_PIN", "");
  91. console.log(LOG_PREFIX, LOG_STYLE, "Created TVDB_SUBSCRIBER_PIN storage");
  92. }
  93.  
  94. if (GM_getValue("TVDB_LANGUAGE") === undefined) {
  95. GM_setValue("TVDB_LANGUAGE", "eng");
  96. console.log(LOG_PREFIX, LOG_STYLE, "Created TVDB_LANGUAGE storage");
  97. }
  98. }
  99.  
  100. // SweetAlert2 Toast
  101. const Toast = Swal.mixin({
  102. toast: true,
  103. position: "bottom-right",
  104. showConfirmButton: false,
  105. timer: 5000,
  106. timerProgressBar: true,
  107. width: "auto",
  108. customClass: {
  109. container: "pgg-toast-container",
  110. },
  111. });
  112.  
  113. // Variables
  114. let rightButtonContainer = null;
  115.  
  116. // Constants
  117. const LOG_PREFIX = "%c🔍 PGG";
  118. const DEBUG_PREFIX = "%c🔍 PGG %cDebug";
  119. const ERROR_PREFIX = "%c🔍 PGG %cError";
  120. const LOG_STYLE = "color: cyan;";
  121. const COLOR_GREEN = "color: lime; font-weight: bold;";
  122. const COLOR_CYAN = "color: cyan; font-weight: bold;";
  123. const ERROR_STYLE = "color: cyan; font-weight: bold; background-color: red;";
  124. const DEBOUNCE_DELAY = 100;
  125. const BUTTON_FADE_DELAY = 50;
  126. const BUTTON_MARGIN = "8px";
  127.  
  128. // User configuration - Set these values in your userscript manager
  129. const USE_SOCIAL_BUTTONS = GM_getValue("USE_SOCIAL_BUTTONS", true);
  130. const SOCIAL_BUTTON_SEPARATION = GM_getValue("SOCIAL_BUTTON_SEPARATION", true);
  131. const USE_PAS = GM_getValue("USE_PAS", false);
  132. const TMDB_API_READ_ACCESS_TOKEN = GM_getValue("TMDB_API_READ_ACCESS_TOKEN", "");
  133. const TMDB_LANGUAGE = GM_getValue("TMDB_LANGUAGE", "en-US");
  134. const TVDB_API_KEY = GM_getValue("TVDB_API_KEY", "");
  135. const TVDB_SUBSCRIBER_PIN = GM_getValue("TVDB_SUBSCRIBER_PIN", "");
  136. const TVDB_LANGUAGE = GM_getValue("TVDB_LANGUAGE", "eng");
  137.  
  138. // Initialize
  139. console.log(LOG_PREFIX, LOG_STYLE, "Plex GUID Grabber v3.5.0");
  140. initializeGMValues();
  141.  
  142. const siteConfig = {
  143. plex: {
  144. id: "plex-guid-button",
  145. name: "Plex",
  146. icon: "https://raw.githubusercontent.com/Soitora/Plex-GUID-Grabber/main/.github/images/plex.webp",
  147. buttonLabel: "Copy Plex GUID",
  148. visible: ["album", "artist", "movie", "season", "episode", "show"],
  149. isYamlButton: false,
  150. isSocialButton: false,
  151. },
  152. imdb: {
  153. id: "imdb-social-button",
  154. name: "IMDb",
  155. icon: "https://raw.githubusercontent.com/Soitora/Plex-GUID-Grabber/main/.github/images/imdb.webp",
  156. buttonLabel: "Open IMDB",
  157. visible: ["movie", "show"],
  158. isYamlButton: false,
  159. isSocialButton: true,
  160. },
  161. tmdb: {
  162. id: "tmdb-social-button",
  163. name: "TMDB",
  164. icon: "https://raw.githubusercontent.com/Soitora/Plex-GUID-Grabber/main/.github/images/tmdb-small.webp",
  165. buttonLabel: "Open TMDB",
  166. visible: ["movie", "show"],
  167. isYamlButton: false,
  168. isSocialButton: true,
  169. },
  170. tvdb: {
  171. id: "tvdb-social-button",
  172. name: "TVDB",
  173. icon: "https://raw.githubusercontent.com/Soitora/Plex-GUID-Grabber/main/.github/images/tvdb.webp",
  174. buttonLabel: "Open TVDB",
  175. visible: ["movie", "show"],
  176. isYamlButton: false,
  177. isSocialButton: true,
  178. },
  179. mbid: {
  180. id: "musicbrainz-social-button",
  181. name: "MusicBrainz",
  182. icon: "https://raw.githubusercontent.com/Soitora/Plex-GUID-Grabber/main/.github/images/musicbrainz.webp",
  183. buttonLabel: "Open MusicBrainz",
  184. visible: ["album", "artist"],
  185. isYamlButton: false,
  186. isSocialButton: true,
  187. },
  188. anidb: {
  189. id: "anidb-social-button",
  190. name: "AniDB",
  191. icon: "https://raw.githubusercontent.com/Soitora/Plex-GUID-Grabber/main/.github/images/anidb.webp",
  192. buttonLabel: "Open AniDB",
  193. visible: ["show", "movie"],
  194. isYamlButton: false,
  195. isSocialButton: true,
  196. },
  197. youtube: {
  198. id: "youtube-social-button",
  199. name: "YouTube",
  200. icon: "https://raw.githubusercontent.com/Soitora/Plex-GUID-Grabber/main/.github/images/youtube.webp",
  201. buttonLabel: "Open YouTube",
  202. visible: ["movie", "show", "episode"],
  203. isYamlButton: false,
  204. isSocialButton: true,
  205. },
  206. tmdbYaml: {
  207. id: "tmdb-yaml-button",
  208. name: "TMDB YAML",
  209. icon: "https://raw.githubusercontent.com/Soitora/Plex-GUID-Grabber/main/.github/images/tmdb-pas.webp",
  210. buttonLabel: "Copy TMDB YAML",
  211. visible: ["movie", "show"],
  212. isYamlButton: true,
  213. isSocialButton: false,
  214. },
  215. tvdbYaml: {
  216. id: "tvdb-yaml-button",
  217. name: "TVDB YAML",
  218. icon: "https://raw.githubusercontent.com/Soitora/Plex-GUID-Grabber/main/.github/images/tvdb-pas.webp",
  219. buttonLabel: "Copy TVDB YAML",
  220. visible: ["movie", "show"],
  221. isYamlButton: true,
  222. isSocialButton: false,
  223. },
  224. };
  225.  
  226. function handleButtons(metadata, pageType, guid) {
  227. const leftButtonContainer = $(document).find(".PageHeaderLeft-pageHeaderLeft-GB_cUK");
  228. const rightButtonContainer = $(document).find(".PageHeaderRight-pageHeaderRight-j9Yjqh");
  229. console.debug(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "Button container found:", rightButtonContainer.length > 0);
  230.  
  231. if (!rightButtonContainer.length || $("#" + siteConfig.plex.id).length) return;
  232.  
  233. const $directory = $(metadata).find("Directory, Video").first();
  234. const title = $directory.attr("parentTitle") || $directory.attr("title");
  235.  
  236. const buttons = createButtonsConfig(guid, pageType, metadata);
  237.  
  238. Object.entries(buttons).forEach(([site, { handler, config }]) => {
  239. if (config.visible.includes(pageType)) {
  240. if (config.isYamlButton && !USE_PAS) return;
  241.  
  242. let shouldShow = true;
  243. if (config.isYamlButton) {
  244. const apiSite = site === "tmdbYaml" ? "tmdb" : "tvdb";
  245. shouldShow = !!guid[apiSite];
  246. }
  247.  
  248. const $button = createButtonElement(config, shouldShow, guid[site], title);
  249.  
  250. if ($button) {
  251. if (site === "plex") {
  252. $button.on("click", () => handlePlexButtonClick(guid[site], config, title));
  253. } else if (config.isYamlButton) {
  254. $button.on("click", async () => handleYamlButtonClick(metadata, site, pageType, guid, title));
  255. } else {
  256. $button.on("click", (e) => handler(e));
  257. }
  258.  
  259. appendButtonToContainer($button, config, rightButtonContainer, leftButtonContainer);
  260.  
  261. setTimeout(() => {
  262. $button.css("opacity", 1);
  263. }, BUTTON_FADE_DELAY);
  264. }
  265. }
  266. });
  267. }
  268.  
  269. function createButtonsConfig(guid, pageType, metadata) {
  270. return Object.keys(siteConfig).reduce((acc, site) => {
  271. acc[site] = {
  272. handler: (event) => handleButtonClick(event, site, guid[site], pageType, metadata),
  273. config: siteConfig[site],
  274. };
  275. return acc;
  276. }, {});
  277. }
  278.  
  279. function createButtonElement(config, shouldShow, guid, title) {
  280. if (!USE_SOCIAL_BUTTONS && config.isSocialButton) {
  281. return null;
  282. }
  283.  
  284. const buttonClasses = ["_1v4h9jl0", "_76v8d62", "_76v8d61", "_76v8d68", "tvbry61", "_76v8d6g", "_76v8d6h", "_1v25wbq1g", "_1v25wbq18"].join(" ");
  285.  
  286. const imageContainerClasses = ["_1h4p3k00", "_1v25wbq8", "_1v25wbq1w", "_1v25wbq1g", "_1v25wbq1c", "_1v25wbq14", "_1v25wbq3g", "_1v25wbq2g"].join(" ");
  287.  
  288. return $("<button>", {
  289. id: config.id,
  290. "aria-label": config.buttonLabel,
  291. class: buttonClasses,
  292. css: {
  293. marginRight: BUTTON_MARGIN,
  294. display: (config.isYamlButton ? shouldShow : guid) ? "block" : "none",
  295. opacity: 0,
  296. transition: "opacity 0.3s ease-in-out",
  297. },
  298. html: `
  299. <div class="${imageContainerClasses}">
  300. <img src="${config.icon}" alt="${config.buttonLabel}" title="${config.buttonLabel}" style="width: 32px; height: 32px;">
  301. </div>
  302. `,
  303. });
  304. }
  305.  
  306. // Utility function for clipboard operations
  307. function copyToClipboard(text, successMessage, errorMessage) {
  308. const formattedText = text.replace(/\n/g, "<br>");
  309.  
  310. // Attempt to use clipboard.js
  311. const tempButton = document.createElement("button");
  312. const clipboard = new ClipboardJS(tempButton, {
  313. text: () => text,
  314. });
  315.  
  316. clipboard.on("success", () => {
  317. Toast.fire({
  318. icon: "success",
  319. title: successMessage,
  320. html: `<span class="pgg-toast-yaml"><strong>Copied Content:</strong><br>${formattedText}</span>`,
  321. });
  322. clipboard.destroy();
  323. });
  324.  
  325. clipboard.on("error", () => {
  326. // Fallback to GM_setClipboard
  327. try {
  328. GM_setClipboard(text);
  329. Toast.fire({
  330. icon: "success",
  331. title: successMessage,
  332. html: `<span class="pgg-toast-yaml"><strong>Copied Content:</strong><br>${formattedText}</span>`,
  333. });
  334. } catch (error) {
  335. console.error(ERROR_PREFIX, ERROR_STYLE, "Failed to copy with GM_setClipboard:", error);
  336. // Fallback to native clipboard API
  337. navigator.clipboard
  338. .writeText(text)
  339. .then(() => {
  340. Toast.fire({
  341. icon: "success",
  342. title: successMessage,
  343. html: `<span class="pgg-toast-yaml"><strong>Copied Content:</strong><br>${formattedText}</span>`,
  344. });
  345. })
  346. .catch((err) => {
  347. console.error(ERROR_PREFIX, ERROR_STYLE, "Failed to copy with native clipboard API:", err);
  348. Toast.fire({
  349. icon: "error",
  350. title: errorMessage,
  351. html: err.message,
  352. });
  353. });
  354. }
  355. });
  356.  
  357. // Trigger the clipboard.js copy action
  358. tempButton.click();
  359. }
  360.  
  361. function handlePlexButtonClick(guid, config, title) {
  362. console.log(LOG_PREFIX, LOG_STYLE, "GUID Output:", guid);
  363. const successMessage = `Copied ${config.name} guid to clipboard.`;
  364. const errorMessage = "Failed to copy guid";
  365. copyToClipboard(guid, successMessage, errorMessage);
  366. }
  367.  
  368. async function handleYamlButtonClick(metadata, site, pageType, guid, title) {
  369. try {
  370. const yamlOutput = await generateYamlOutput(metadata, site, pageType, guid);
  371. console.log(LOG_PREFIX, LOG_STYLE, "YAML Output:\n", yamlOutput);
  372. if (yamlOutput) {
  373. const successMessage = `Copied ${siteConfig[site].name} output to clipboard.`;
  374. const errorMessage = "Failed to copy YAML output";
  375. copyToClipboard(yamlOutput, successMessage, errorMessage);
  376. }
  377. } catch (error) {
  378. console.error(ERROR_PREFIX, ERROR_STYLE, "Failed to generate YAML:", error);
  379. Toast.fire({
  380. icon: "error",
  381. title: "Failed to generate YAML",
  382. html: error.message,
  383. });
  384. }
  385. }
  386.  
  387. function appendButtonToContainer($button, config, rightButtonContainer, leftButtonContainer) {
  388. if (config.isYamlButton || config.id === siteConfig.plex.id) {
  389. rightButtonContainer.prepend($button);
  390. } else {
  391. if (SOCIAL_BUTTON_SEPARATION) {
  392. leftButtonContainer.append($button);
  393. } else {
  394. rightButtonContainer.prepend($button);
  395. }
  396. }
  397. }
  398.  
  399. function checkApiKeys(site) {
  400. if (site === "tmdb" && !TMDB_API_READ_ACCESS_TOKEN) {
  401. Toast.fire({
  402. icon: "error",
  403. title: "TMDB Read Access Token Missing",
  404. html: "Please set your TMDB Read Access Token in the userscript settings",
  405. });
  406. return false;
  407. }
  408. if (site === "tvdb" && !TVDB_API_KEY) {
  409. Toast.fire({
  410. icon: "error",
  411. title: "TVDB API Key Missing",
  412. html: "Please set your TVDB API key in the userscript settings",
  413. });
  414. return false;
  415. }
  416. return true;
  417. }
  418.  
  419. async function handleButtonClick(event, site, guid, pageType, metadata) {
  420. console.debug(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "Button clicked:", site, guid, pageType);
  421.  
  422. let title = $(metadata).find("Directory, Video").first();
  423. title = title.attr("parentTitle") || title.attr("title");
  424.  
  425. const urlMap = {
  426. imdb: `https://www.imdb.com/title/${guid}/`,
  427. tmdb: pageType === "movie" ? `https://www.themoviedb.org/movie/${guid}` : `https://www.themoviedb.org/tv/${guid}`,
  428. tvdb: pageType === "movie" ? `https://www.thetvdb.com/dereferrer/movie/${guid}` : `https://www.thetvdb.com/dereferrer/series/${guid}`,
  429. mbid: pageType === "album" ? `https://musicbrainz.org/album/${guid}` : `https://musicbrainz.org/artist/${guid}`,
  430. anidb: `https://anidb.net/anime/${guid}`,
  431. youtube: `https://www.youtube.com/watch?v=${guid}`,
  432. };
  433.  
  434. const url = urlMap[site];
  435.  
  436. if (!siteConfig[site].visible.includes(pageType)) {
  437. Toast.fire({
  438. icon: "warning",
  439. title: `${siteConfig[site].name} links are not available for ${pageType} pages.`,
  440. });
  441. return;
  442. }
  443.  
  444. if (!guid) {
  445. Toast.fire({
  446. icon: "warning",
  447. title: `No ${siteConfig[site].name} GUID found for this item.`,
  448. });
  449. return;
  450. }
  451.  
  452. if (url) {
  453. const ctrlClick = event.ctrlKey || event.metaKey;
  454. const newTab = window.open(url, "_blank");
  455.  
  456. if (!ctrlClick) {
  457. newTab.focus();
  458. }
  459.  
  460. Toast.fire({
  461. icon: "success",
  462. title: `Opened ${siteConfig[site].name} in a new tab.`,
  463. });
  464. }
  465. }
  466.  
  467. async function getGuid(metadata) {
  468. if (!metadata) return null;
  469.  
  470. const $directory = $(metadata).find("Directory, Video").first();
  471. console.debug(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "Directory/Video:", $directory[0]);
  472. //console.debug(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "Directory/Video outerHTML:", $directory[0]?.outerHTML);
  473. //console.debug(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "Directory/Video innerHTML:", $directory[0]?.innerHTML);
  474.  
  475. if (!$directory.length) {
  476. console.error(ERROR_PREFIX, ERROR_STYLE, "Main element not found in XML");
  477. return null;
  478. }
  479.  
  480. const guid = initializeGuid($directory);
  481.  
  482. if (guid.plex?.startsWith("com.plexapp.agents.hama://")) {
  483. extractHamaGuid(guid, guid.plex);
  484. }
  485.  
  486. $directory.find("Guid").each(function () {
  487. const guidId = $(this).attr("id");
  488. if (guidId) {
  489. const [service, value] = guidId.split("://");
  490. if (service && value) {
  491. extractGuid(guid, service, value);
  492. }
  493. }
  494. });
  495.  
  496. return guid;
  497. }
  498.  
  499. function initializeGuid($directory) {
  500. return {
  501. plex: $directory.attr("guid"),
  502. imdb: null,
  503. tmdb: null,
  504. tvdb: null,
  505. mbid: null,
  506. anidb: null,
  507. youtube: null,
  508. };
  509. }
  510.  
  511. function extractHamaGuid(guid, plexGuid) {
  512. const match = plexGuid.match(/com\.plexapp\.agents\.hama:\/\/(\w+)-(\d+)/);
  513. if (match) {
  514. extractGuid(guid, match[1], match[2]);
  515. }
  516. }
  517.  
  518. function extractGuid(guid, service, value) {
  519. const normalizedService = service.toLowerCase();
  520. if (normalizedService.startsWith("tsdb")) {
  521. guid.tmdb = value;
  522. } else if (guid.hasOwnProperty(normalizedService)) {
  523. guid[normalizedService] = value;
  524. }
  525. }
  526.  
  527. async function getLibraryMetadata(metadataPoster) {
  528. const img = metadataPoster.find("img").first();
  529. if (!img?.length) {
  530. console.debug(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "No image found in metadata poster");
  531. return null;
  532. }
  533.  
  534. const imgSrc = img.attr("src");
  535. if (!imgSrc) {
  536. console.debug(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "No src attribute found in image");
  537. return null;
  538. }
  539.  
  540. const url = new URL(imgSrc);
  541. const serverUrl = `${url.protocol}//${url.host}`;
  542. const plexToken = url.searchParams.get("X-Plex-Token");
  543. const urlParam = url.searchParams.get("url");
  544. const metadataKey = urlParam?.match(/\/library\/metadata\/(\d+)/)?.[1];
  545.  
  546. if (!plexToken || !metadataKey) {
  547. console.debug(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "Missing plexToken or metadataKey", { plexToken: !!plexToken, metadataKey: !!metadataKey });
  548. return null;
  549. }
  550.  
  551. try {
  552. const response = await fetch(`${serverUrl}/library/metadata/${metadataKey}?X-Plex-Token=${plexToken}`);
  553. if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
  554. return new DOMParser().parseFromString(await response.text(), "text/xml");
  555. } catch (error) {
  556. console.error(ERROR_PREFIX, ERROR_STYLE, "Failed to fetch metadata:", error.message);
  557. return null;
  558. }
  559. }
  560.  
  561. async function observeMetadataPoster() {
  562. let isObserving = true;
  563.  
  564. const observer = new MutationObserver(
  565. debounce(async () => {
  566. if (!isObserving) return;
  567.  
  568. if (!window.location.href.includes("%2Flibrary%2Fmetadata%2")) {
  569. isObserving = false;
  570. console.debug(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "Not a metadata page.");
  571. return;
  572. }
  573.  
  574. const $metadataPoster = $("div[data-testid='metadata-poster']");
  575. //console.debug(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "Metadata poster found:", $metadataPoster.length > 0);
  576.  
  577. if (!$metadataPoster.length) return;
  578.  
  579. isObserving = false;
  580. const metadata = await getLibraryMetadata($metadataPoster);
  581. console.debug(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "Metadata retrieved:", !!metadata);
  582.  
  583. const pageType = $(metadata).find("Directory, Video").first().attr("type");
  584. let title = $(metadata).find("Directory, Video").first();
  585. title = title.attr("parentTitle") || title.attr("title");
  586.  
  587. console.log(LOG_PREFIX, LOG_STYLE, "Type:", pageType);
  588. console.log(LOG_PREFIX, LOG_STYLE, "Title:", title);
  589.  
  590. if (pageType) {
  591. const guid = await getGuid(metadata);
  592. console.log(LOG_PREFIX, LOG_STYLE, "Guid:", guid);
  593.  
  594. if (guid) {
  595. handleButtons(metadata, pageType, guid);
  596. }
  597. }
  598. }, DEBOUNCE_DELAY)
  599. );
  600.  
  601. observer.observe(document.body, {
  602. childList: true,
  603. subtree: true,
  604. attributes: true,
  605. attributeFilter: ["data-page-type"],
  606. });
  607.  
  608. const handleNavigation = debounce(() => {
  609. isObserving = true;
  610. console.debug(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "Navigation detected - resuming observation.");
  611. }, DEBOUNCE_DELAY);
  612.  
  613. $(window).on("hashchange popstate", handleNavigation);
  614. }
  615.  
  616. function debounce(func, wait) {
  617. let timeout;
  618. return function (...args) {
  619. const context = this;
  620. clearTimeout(timeout);
  621. timeout = setTimeout(() => func.apply(context, args), wait);
  622. };
  623. }
  624.  
  625. async function getTVDBToken() {
  626. const LOGIN_URL = "https://api4.thetvdb.com/v4/login";
  627. const API_KEY = TVDB_API_KEY;
  628. const PIN = TVDB_SUBSCRIBER_PIN;
  629.  
  630. try {
  631. const response = await fetch(LOGIN_URL, {
  632. method: "POST",
  633. headers: {
  634. "Content-Type": "application/json",
  635. Accept: "application/json",
  636. },
  637. body: JSON.stringify({ apikey: API_KEY, pin: PIN }),
  638. });
  639.  
  640. //console.log(DEBUG_PREFIX, "TVDB Token Response:", response);
  641.  
  642. if (!response.ok) {
  643. throw new Error(`Login failed: ${response.status} ${response.statusText}`);
  644. }
  645.  
  646. const data = await response.json();
  647. //console.log(DEBUG_PREFIX, "TVDB Token Data:", data.data);
  648.  
  649. return data.data.token;
  650. } catch (error) {
  651. console.error(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "Authentication error:", error);
  652. return null;
  653. }
  654. }
  655.  
  656. async function fetchApiData(url, headers = {}) {
  657. return new Promise((resolve, reject) => {
  658. GM_xmlhttpRequest({
  659. method: "GET",
  660. url: url,
  661. headers: {
  662. ...headers,
  663. },
  664. onload: function (response) {
  665. if (response.status >= 200 && response.status < 300) {
  666. try {
  667. const data = JSON.parse(response.responseText);
  668. resolve(data);
  669. } catch (error) {
  670. console.error(ERROR_PREFIX, ERROR_STYLE, "Failed to parse JSON response:", error);
  671. Toast.fire({
  672. icon: "error",
  673. title: "API Error",
  674. html: "Failed to parse JSON response",
  675. });
  676. reject(new Error("Failed to parse JSON response"));
  677. }
  678. } else {
  679. console.error(ERROR_PREFIX, ERROR_STYLE, `API error: ${response.status} - ${response.responseText}`);
  680. Toast.fire({
  681. icon: "error",
  682. title: "API Error",
  683. html: `Status: ${response.status} - ${response.responseText}`,
  684. });
  685. reject(new Error(`API error: ${response.status} - ${response.responseText}`));
  686. }
  687. },
  688. onerror: function (error) {
  689. console.error(ERROR_PREFIX, ERROR_STYLE, "Network error:", error);
  690. Toast.fire({
  691. icon: "error",
  692. title: "Network Error",
  693. html: error.message,
  694. });
  695. reject(new Error(`Network error: ${error}`));
  696. },
  697. });
  698. });
  699. }
  700.  
  701. async function generateYamlOutput(metadata, site, pageType, guid) {
  702. const apiSite = site === "tmdbYaml" ? "tmdb" : "tvdb";
  703.  
  704. if (!checkApiKeys(apiSite)) return "";
  705.  
  706. const mediaType = pageType === "movie" ? "movie" : "tv";
  707. const $directory = $(metadata).find("Directory, Video").first();
  708. const plex_guid = $directory.attr("guid");
  709.  
  710. try {
  711. const { title, numberOfSeasons } = await fetchTitleAndSeasons(apiSite, mediaType, guid);
  712. return constructYamlOutput(title, plex_guid, numberOfSeasons, guid, mediaType);
  713. } catch (error) {
  714. console.error(ERROR_PREFIX, ERROR_STYLE, "Error generating YAML output:", error);
  715. return "";
  716. }
  717. }
  718.  
  719. async function fetchTitleAndSeasons(apiSite, mediaType, guid) {
  720. if (apiSite === "tmdb") {
  721. return fetchTmdbData(mediaType, guid[apiSite]);
  722. } else if (apiSite === "tvdb") {
  723. return fetchTvdbData(mediaType, guid[apiSite]);
  724. }
  725. }
  726.  
  727. async function fetchTmdbData(mediaType, tmdbId) {
  728. const url =
  729. mediaType === "tv" ? `https://api.themoviedb.org/3/tv/${tmdbId}?language=${TMDB_LANGUAGE}` : `https://api.themoviedb.org/3/movie/${tmdbId}?language=${TMDB_LANGUAGE}`;
  730.  
  731. const data = await fetchApiData(url, {
  732. Accept: "application/json",
  733. Authorization: `Bearer ${TMDB_API_READ_ACCESS_TOKEN}`,
  734. });
  735.  
  736. const title = mediaType === "tv" ? data.name : data.title;
  737. const numberOfSeasons = mediaType === "tv" ? data.number_of_seasons || 1 : 1;
  738.  
  739. return { title, numberOfSeasons };
  740. }
  741.  
  742. async function fetchTvdbData(mediaType, tvdbId) {
  743. const tvdbBearerToken = await getTVDBToken();
  744. if (!tvdbBearerToken) {
  745. console.error(ERROR_PREFIX, ERROR_STYLE, "Failed to retrieve TVDB token.");
  746. return { title: "", numberOfSeasons: 1 };
  747. }
  748.  
  749. const url =
  750. mediaType === "tv"
  751. ? `https://api4.thetvdb.com/v4/series/${tvdbId}/translations/${TVDB_LANGUAGE}`
  752. : `https://api4.thetvdb.com/v4/movies/${tvdbId}/translations/${TVDB_LANGUAGE}`;
  753.  
  754. const data = await fetchApiData(url, {
  755. Accept: "application/json",
  756. Authorization: `Bearer ${tvdbBearerToken}`,
  757. });
  758.  
  759. const title = data.data.name;
  760. const numberOfSeasons = mediaType === "tv" ? await fetchTvdbSeasons(tvdbId, tvdbBearerToken) : 1;
  761.  
  762. return { title, numberOfSeasons };
  763. }
  764.  
  765. async function fetchTvdbSeasons(tvdbId, tvdbBearerToken) {
  766. const episodesData = await fetchApiData(`https://api4.thetvdb.com/v4/series/${tvdbId}/episodes/default/${TVDB_LANGUAGE}`, {
  767. Accept: "application/json",
  768. Authorization: `Bearer ${tvdbBearerToken}`,
  769. });
  770.  
  771. const seriesSeasons = new Set();
  772. episodesData.data.episodes.forEach((episode) => {
  773. if (episode.seasonNumber !== 0) {
  774. seriesSeasons.add(episode.seasonNumber);
  775. }
  776. });
  777.  
  778. return seriesSeasons.size || 1;
  779. }
  780.  
  781. function constructYamlOutput(title, plex_guid, numberOfSeasons, guid, mediaType) {
  782. const data = [
  783. {
  784. title: title,
  785. guid: plex_guid,
  786. seasons: Array.from({ length: numberOfSeasons }, (_, i) => ({
  787. season: i + 1,
  788. "anilist-id": 0,
  789. })),
  790. },
  791. ];
  792.  
  793. let yamlOutput = jsyaml.dump(data, {
  794. quotingType: `"`,
  795. forceQuotes: { title: true },
  796. indent: 2,
  797. });
  798.  
  799. yamlOutput = yamlOutput.replace(/^(\s*guid: )"([^"]+)"$/gm, "$1$2").trim();
  800.  
  801. const url_IMDB = guid.imdb ? `\n # imdb: https://www.imdb.com/title/${guid.imdb}/` : "";
  802. const url_TMDB = guid.tmdb ? `\n # tmdb: https://www.themoviedb.org/${mediaType}/${guid.tmdb}` : "";
  803. const url_TVDB = guid.tvdb ? `\n # tvdb: https://www.thetvdb.com/dereferrer/${mediaType === "tv" ? "series" : "movie"}/${guid.tvdb}` : "";
  804.  
  805. const guidRegex = /^(\s*guid:.*)$/m;
  806. return yamlOutput
  807. .replace(guidRegex, `$1${url_IMDB}${url_TMDB}${url_TVDB}`)
  808. .replace(/^/gm, " ")
  809. .replace(/^\s\s$/gm, "\n");
  810. }
  811.  
  812. $(document).ready(observeMetadataPoster);