Plex GUID Grabber

Grab the GUID of a Plex entry on demand

当前为 2025-01-30 提交的版本,查看 最新版本

  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.0.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/clipboard.js/2.0.11/clipboard.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_addStyle
  16. // @grant GM_getResourceText
  17. // @run-at document-end
  18. // ==/UserScript==
  19.  
  20. GM_addStyle(`button[id$="-guid-button"] {
  21. margin-right: 4px;
  22. }
  23.  
  24. button[id$="-guid-button"]:not([id="imdb-guid-button"]):hover img {
  25. filter: invert(100%) grayscale(100%) contrast(120%);
  26. }
  27.  
  28. button[id="imdb-guid-button"]:hover img {
  29. filter: grayscale(100%) contrast(120%);
  30. }
  31.  
  32. button[id="imdb-guid-button"] img {
  33. width: 30px !important;
  34. height: 30px !important;
  35. }
  36. `);
  37.  
  38. const Toast = Swal.mixin({
  39. toast: true,
  40. position: "bottom-right",
  41. showConfirmButton: false,
  42. timer: 5000,
  43. timerProgressBar: true,
  44. });
  45.  
  46. // Variables
  47. const buttonConfig = {
  48. plex: {
  49. id: "plex-guid-button",
  50. label: "Copy Plex GUID",
  51. icon: "https://raw.githubusercontent.com/Soitora/PlexAniSync-Mapping-Assistant/main/.github/icons/plex.opti.png",
  52. },
  53. imdb: {
  54. id: "imdb-guid-button",
  55. label: "Open IMDB",
  56. icon: "https://raw.githubusercontent.com/Soitora/PlexAniSync-Mapping-Assistant/main/.github/icons/imdb.opti.png",
  57. },
  58. tmdb: {
  59. id: "tmdb-guid-button",
  60. label: "Open TMDB",
  61. icon: "https://raw.githubusercontent.com/Soitora/PlexAniSync-Mapping-Assistant/main/.github/icons/tmdb-small.opti.png",
  62. },
  63. tvdb: {
  64. id: "tvdb-guid-button",
  65. label: "Open TVDB",
  66. icon: "https://raw.githubusercontent.com/Soitora/PlexAniSync-Mapping-Assistant/main/.github/icons/tvdb.opti.png",
  67. },
  68. };
  69.  
  70. const buttonVisibility = {
  71. plex: ["album", "artist", "movie", "season", "episode", "show"],
  72. imdb: ["movie", "show"],
  73. tmdb: ["movie", "show"],
  74. tvdb: ["movie", "show"],
  75. };
  76.  
  77. const siteDisplayNames = {
  78. plex: "Plex",
  79. imdb: "IMDb",
  80. tmdb: "TMDB",
  81. tvdb: "TVDB",
  82. };
  83.  
  84. let buttonContainer = null;
  85. let clipboard = null;
  86.  
  87. // Initialize
  88. console.log("\x1b[36mPGG", "🔍 Plex GUID Grabber");
  89.  
  90. function handleButtons(metadata, pageType, guid) {
  91. const buttonContainer = $(document).find(".PageHeaderRight-pageHeaderRight-j9Yjqh");
  92. console.debug("\x1b[36mPGG \x1b[32mDebug", "Button container found:", buttonContainer.length > 0);
  93.  
  94. // Check if container exists or button already exists
  95. if (!buttonContainer.length || $("#" + buttonConfig.plex.id).length) return;
  96.  
  97. const buttons = {
  98. plex: {
  99. handler: () => handleButtonClick("plex", guid.plex, pageType, metadata),
  100. config: buttonConfig.plex,
  101. },
  102. tmdb: {
  103. handler: () => handleButtonClick("tmdb", guid.tmdb, pageType, metadata),
  104. config: buttonConfig.tmdb,
  105. },
  106. tvdb: {
  107. handler: () => handleButtonClick("tvdb", guid.tvdb, pageType, metadata),
  108. config: buttonConfig.tvdb,
  109. },
  110. imdb: {
  111. handler: () => handleButtonClick("imdb", guid.imdb, pageType, metadata),
  112. config: buttonConfig.imdb,
  113. },
  114. };
  115.  
  116. Object.entries(buttons).forEach(([site, { handler, config }]) => {
  117. if (buttonVisibility[site].includes(pageType)) {
  118. const $button = $("<button>", {
  119. id: config.id,
  120. "aria-label": config.label,
  121. class: "_1v4h9jl0 _76v8d62 _76v8d61 _76v8d68 tvbry61 _76v8d6g _76v8d6h _1v25wbq1g _1v25wbq18",
  122. css: {
  123. marginRight: "8px",
  124. display: guid[site] ? "block" : "none",
  125. opacity: 0,
  126. transition: "opacity 0.3s ease-in-out",
  127. },
  128. html: `
  129. <div class="_1h4p3k00 _1v25wbq8 _1v25wbq1w _1v25wbq1g _1v25wbq1c _1v25wbq14 _1v25wbq3g _1v25wbq2g">
  130. <img src="${config.icon}" alt="${config.label}" title="${config.label}" style="width: 32px; height: 32px;">
  131. </div>
  132. `,
  133. }).on("click", handler);
  134.  
  135. buttonContainer.prepend($button);
  136.  
  137. setTimeout(() => {
  138. $button.css("opacity", 1);
  139. }, 50);
  140. }
  141. });
  142. }
  143.  
  144. async function handleButtonClick(site, guid, pageType, metadata) {
  145. console.debug("\x1b[36mPGG \x1b[32mDebug", "Button clicked:", site, guid, pageType);
  146.  
  147. let title = $(metadata).find("Directory, Video").first();
  148. title = title.attr("parentTitle") || title.attr("title");
  149.  
  150. const urlMap = {
  151. imdb: `https://www.imdb.com/title/${guid}/`,
  152. tmdb: pageType === "movie" ? `https://www.themoviedb.org/movie/${guid}` : `https://www.themoviedb.org/tv/${guid}`,
  153. tvdb: pageType === "movie" ? `https://www.thetvdb.com/dereferrer/movie/${guid}` : `https://www.thetvdb.com/dereferrer/series/${guid}`,
  154. };
  155.  
  156. const url = urlMap[site];
  157.  
  158. if (!buttonVisibility[site].includes(pageType)) {
  159. Toast.fire({
  160. icon: "warning",
  161. title: `${site} links are not available for ${pageType} pages.`,
  162. });
  163. return;
  164. }
  165.  
  166. if (!guid) {
  167. Toast.fire({
  168. icon: "warning",
  169. title: `No ${site} GUID found for this item.`,
  170. });
  171. return;
  172. }
  173.  
  174. if (site === "plex") {
  175. // Destroy existing clipboard instance if it exists
  176. if (clipboard) {
  177. clipboard.destroy();
  178. clipboard = null;
  179. }
  180.  
  181. // Create new clipboard instance
  182. clipboard = new ClipboardJS(`#${buttonConfig.plex.id}`, {
  183. text: () => guid,
  184. });
  185.  
  186. clipboard.on("success", (e) => {
  187. Toast.fire({
  188. icon: "success",
  189. title: `Copied Plex guid to clipboard.`,
  190. html: `<span><strong>${title}</strong><br>${guid}</span>`,
  191. });
  192. e.clearSelection();
  193. });
  194.  
  195. clipboard.onClick({
  196. currentTarget: $(`#${buttonConfig.plex.id}`)[0],
  197. });
  198. return;
  199. }
  200.  
  201. if (url) {
  202. window.open(url, "_blank");
  203. Toast.fire({
  204. icon: "success",
  205. title: `Opened ${site.toUpperCase()} in a new tab.`,
  206. });
  207. }
  208. }
  209.  
  210. async function getGuid(metadata) {
  211. if (!metadata) return null;
  212.  
  213. const $directory = $(metadata).find("Directory, Video").first();
  214.  
  215. // Add debug logging for Directory/Video element
  216. console.debug("\x1b[36mPGG \x1b[32mDebug", "Directory/Video outerHTML:", $directory[0]?.outerHTML);
  217. console.debug("\x1b[36mPGG \x1b[32mDebug", "Directory/Video innerHTML:", $directory[0]?.innerHTML);
  218.  
  219. if (!$directory.length) {
  220. console.error("\x1b[36mPGG \x1b[31mError", "Main element not found in XML");
  221. return null;
  222. }
  223.  
  224. const guid = {
  225. plex: $directory.attr("guid"),
  226. imdb: null,
  227. tmdb: null,
  228. tvdb: null,
  229. };
  230.  
  231. $directory.find("Guid").each(function () {
  232. const [service, value] = $(this).attr("id")?.split("://") ?? [];
  233. if (service && guid.hasOwnProperty(service.toLowerCase())) {
  234. guid[service.toLowerCase()] = value;
  235. }
  236. });
  237.  
  238. return guid;
  239. }
  240.  
  241. async function getLibraryMetadata(metadataPoster) {
  242. const img = metadataPoster.find("img").first();
  243. if (!img?.length) return null;
  244.  
  245. const imgSrc = img.attr("src");
  246. if (!imgSrc) return null;
  247.  
  248. const url = new URL(imgSrc);
  249. const serverUrl = `${url.protocol}//${url.host}`;
  250. const plexToken = url.searchParams.get("X-Plex-Token");
  251. const urlParam = url.searchParams.get("url");
  252. const metadataKey = urlParam?.match(/\/library\/metadata\/(\d+)/)?.[1];
  253.  
  254. if (!plexToken || !metadataKey) return null;
  255.  
  256. try {
  257. const response = await fetch(`${serverUrl}/library/metadata/${metadataKey}?X-Plex-Token=${plexToken}`);
  258. if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
  259. return new DOMParser().parseFromString(await response.text(), "text/xml");
  260. } catch (error) {
  261. console.error("\x1b[36mPGG \x1b[31mError", "Failed to fetch metadata:", error.message);
  262. return null;
  263. }
  264. }
  265.  
  266. async function observeMetadataPoster() {
  267. let isObserving = true;
  268.  
  269. const observer = new MutationObserver(
  270. debounce(async () => {
  271. if (!isObserving) return;
  272.  
  273. if (!window.location.href.includes("%2Flibrary%2Fmetadata%2")) {
  274. isObserving = false;
  275. console.debug("\x1b[36mPGG \x1b[32mDebug", "Not a metadata page.");
  276. return;
  277. }
  278.  
  279. const $metadataPoster = $("div[data-testid='metadata-poster']");
  280. console.debug("\x1b[36mPGG \x1b[32mDebug", "Metadata poster found:", $metadataPoster.length > 0);
  281.  
  282. if (!$metadataPoster.length) return;
  283.  
  284. isObserving = false;
  285. const metadata = await getLibraryMetadata($metadataPoster);
  286. console.debug("\x1b[36mPGG \x1b[32mDebug", "Metadata retrieved:", !!metadata);
  287.  
  288. const pageType = $(metadata).find("Directory, Video").first().attr("type");
  289. let title = $(metadata).find("Directory, Video").first();
  290. title = title.attr("parentTitle") || title.attr("title");
  291.  
  292. console.log("\x1b[36mPGG", "Type:", pageType);
  293. console.log("\x1b[36mPGG", "Title:", title);
  294.  
  295. if (pageType) {
  296. const guid = await getGuid(metadata);
  297. console.log("\x1b[36mPGG", "Guid:", guid);
  298.  
  299. if (guid) {
  300. handleButtons(metadata, pageType, guid);
  301. }
  302. }
  303. }, 100)
  304. );
  305.  
  306. observer.observe(document.body, {
  307. childList: true,
  308. subtree: true,
  309. attributes: true,
  310. attributeFilter: ["data-page-type"],
  311. });
  312.  
  313. const handleNavigation = debounce(() => {
  314. isObserving = true;
  315. console.debug("\x1b[36mPGG \x1b[32mDebug", "Navigation detected - resuming observation.");
  316. }, 100);
  317.  
  318. $(window).on("hashchange popstate", handleNavigation);
  319. }
  320.  
  321. function debounce(func, wait) {
  322. let timeout;
  323. return function (...args) {
  324. const context = this;
  325. clearTimeout(timeout);
  326. timeout = setTimeout(() => func.apply(context, args), wait);
  327. };
  328. }
  329.  
  330. $(document).ready(observeMetadataPoster);