Plex GUID Grabber

Grab the GUID of a Plex entry on demand

目前为 2025-02-09 提交的版本,查看 最新版本

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