Plex External Player

Play plex videos in an external player

当前为 2024-02-23 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Plex External Player
  3. // @namespace https://github.com/Kayomani/PlexExternalPlayer
  4. // @version 1.23
  5. // @description Play plex videos in an external player
  6. // @author Kayomani, 1LucKyLuke
  7. // @include /^https?://.*:32400/web.*
  8. // @include http://*:32400/web/index.html*
  9. // @include https://*:32400/web/index.html*
  10. // @include https://app.plex.tv/*
  11. // @require http://code.jquery.com/jquery-3.2.1.min.js
  12. // @connect *
  13. // @require https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/js/toastr.min.js
  14. // @grant GM_xmlhttpRequest
  15. // ==/UserScript==
  16.  
  17. $("head").append(
  18. '<link href="//cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/css/toastr.min.css" rel="stylesheet" type="text/css">'
  19. );
  20.  
  21.  
  22. toastr.options = {
  23. "closeButton": true,
  24. "debug": false,
  25. "newestOnTop": true,
  26. "progressBar": true,
  27. "positionClass": "toast-bottom-right",
  28. "preventDuplicates": false,
  29. "onclick": null,
  30. "showDuration": "300",
  31. "hideDuration": "1000",
  32. "timeOut": "5000",
  33. "extendedTimeOut": "1000",
  34. "showEasing": "swing",
  35. "hideEasing": "linear",
  36. "showMethod": "fadeIn",
  37. "hideMethod": "fadeOut"
  38. };
  39.  
  40. var showToast = function (msg, error) {
  41. var title = 'Plex External Player';
  42. if (error) {
  43. toastr.error(msg, title, { timeOut: 10000 });
  44. logMessage(msg);
  45. }
  46. else {
  47. toastr.success(msg, title);
  48. }
  49. };
  50.  
  51. var logMessage = function (msg) {
  52. console.log('[Plex External] ' + msg);
  53. };
  54.  
  55. var makeRequest = function (url, serverId) {
  56. return new Promise(function (resolve, reject) {
  57. var origAccessToken = localStorage.myPlexAccessToken;
  58. var serverNode = {};
  59. if (localStorage.users) {
  60. serverNode = JSON.parse(localStorage.users);
  61. } else {
  62. logMessage('User details not found');
  63. }
  64. var tokenToTry = origAccessToken;
  65. if (serverNode === undefined) {
  66. serverNode = {
  67. users: []
  68. };
  69. }
  70. logMessage('Looking for token of serverId:'+serverId)
  71. let tokenFound = false
  72. if (serverId !== undefined) {
  73. serverLoop:
  74. for (var i = 0; i < serverNode.users.length; i++) {
  75. logMessage('Checking server list ('+serverNode.users[i].servers.length+') for user:' +serverNode.users[i].username)
  76. for (var j = 0; j < serverNode.users[i].servers.length; j++) {
  77. logMessage('Checking server with id '+serverNode.users[i].servers[j].machineIdentifier)
  78. if (serverNode.users[i].servers[j].machineIdentifier == serverId) {
  79. tokenToTry = serverNode.users[i].servers[j].accessToken;
  80. logMessage('Token found:' + tokenToTry);
  81. tokenFound = true
  82. break serverLoop;
  83. }
  84. }
  85. }
  86. if (!tokenFound){
  87. showToast('Could not find authentication info', 1);
  88. reject();
  89. return;
  90. }
  91. }
  92.  
  93. var authedUrl = url + '&X-Plex-Token=' + tokenToTry;
  94. logMessage('Calling ' + authedUrl);
  95. GM_xmlhttpRequest({
  96. method: "GET",
  97.  
  98. url: authedUrl,
  99. onload: function (state) {
  100. if (state.status === 200) {
  101. logMessage('Called sucessfully to ' + url);
  102. resolve(state);
  103. }
  104. },
  105. onreadystatechange: function (state) {
  106. if (state.readyState === 4) {
  107.  
  108. if (state.status === 401) {
  109. logMessage('Not Authorised ' + url);
  110. } else if (state.status !== 200) {
  111. logMessage('Request returned ' + state.status);
  112. showToast('Error calling: ' + url + '. Response: ' + state.responseText + ' Code:' + state.status + ' Message: ' + state.statusText, 1);
  113. }
  114. }
  115. },
  116. });
  117. });
  118. };
  119.  
  120.  
  121.  
  122. var markAsPlayedInPlex = function (id, serverId) {
  123. logMessage('Marking ' + id + ' as played');
  124. return makeRequest(pmsUrls.get(serverId) + '/:/scrobble?key=' + id + '&identifier=com.plexapp.plugins.library', serverId).catch(function () {
  125. showToast('Failed to mark item ' + id + ' as played');
  126. });
  127. };
  128.  
  129. var openItemOnAgent = function (path, id, openFolder, serverId) {
  130. if (openFolder) {
  131. var fwd = path.lastIndexOf('/');
  132. var bck = path.lastIndexOf('\\');
  133. var best = fwd > bck ? fwd : bck;
  134. if (best > -1) {
  135. path = path.substr(0, best);
  136. }
  137. }
  138. showToast('Playing ' + path, 0);
  139. logMessage('Playing ' + path);
  140. // umicrosharp doesn't handle plus properly
  141. path = path.replace(/\+/g, '[PLEXEXTPLUS]');
  142. var url = 'http://localhost:7251/?protocol=2&item=' + encodeURIComponent(path);
  143. return new Promise(function (resolve, reject) {
  144. makeRequest(url).then(function () {
  145. if (!openFolder) {
  146. markAsPlayedInPlex(id, serverId).then(resolve, reject);
  147. }
  148. }, reject);
  149. });
  150. };
  151.  
  152. var clickListener = function (e) {
  153. e.preventDefault();
  154. e.stopPropagation();
  155. var a = jQuery(e.target).closest('a');
  156. var link = a.attr('href');
  157. var openFolder = jQuery(e.target).attr('title') === 'Open folder';
  158. var url = link;
  159. if (link === '#' || link === undefined || link === 'javascript:void(0)') {
  160. url = window.location.hash;
  161. }
  162.  
  163. if (url.indexOf('/server/') > -1) {
  164. var serverId = url.split('/')[2];
  165. }
  166.  
  167. if (url.indexOf('%2Fmetadata%2F') > -1) {
  168. var idx = url.indexOf('%2Fmetadata%2F');
  169. var id = url.substr(idx + 14);
  170. var idToken = id.indexOf('&');
  171. if (idToken > -1) {
  172. id = id.substr(0, idToken);
  173. }
  174.  
  175. // Get metadata
  176. let metaDataPath = pmsUrls.get(serverId) + '/library/metadata/' + id + '?includeConcerts=1&includeExtras=1&includeOnDeck=1&includePopularLeaves=1&includePreferences=1&includeChapters=1&asyncCheckFiles=0&asyncRefreshAnalysis=0&asyncRefreshLocalMediaAgent=0';
  177. makeRequest(metaDataPath, serverId)
  178. .then(function (response) {
  179. // Play the first availible part
  180. let parts = response.responseXML.getElementsByTagName('Part');
  181. for (let i = 0; i < parts.length; i++) {
  182. if (parts[i].attributes['file'] !== undefined) {
  183. let videoId = parts[i].parentNode.parentNode.attributes['ratingKey'].value;
  184. if (videoId !== undefined) {
  185. id = videoId;
  186. }
  187. openItemOnAgent(parts[i].attributes['file'].value, id, openFolder, serverId).catch(function () {
  188. showToast('Failed to connect to agent, is it running or firewalled?', 1);
  189. });
  190. return;
  191. }
  192. }
  193.  
  194. if (parts.length === 0) {
  195. // If we got a directory/Season back then get the files in it
  196. var dirs = response.responseXML.getElementsByTagName('Directory');
  197. if (dirs.length > 0) {
  198. makeRequest(pmsUrls.get(serverId) + dirs[0].attributes['key'].value + '?includeConcerts=1&includeExtras=1&includeOnDeck=1&includePopularLeaves=1&includePreferences=1&includeChapters=1&asyncCheckFiles=0&asyncRefreshAnalysis=0&asyncRefreshLocalMediaAgent=0',serverId)
  199. .then(function (response) {
  200. var videos = response.responseXML.getElementsByTagName('Video');
  201. var file = null;
  202. var id = null;
  203. if (videos.length === 0) {
  204. showToast('Could not determine which video to play as there are multiple seasons.', true);
  205. return;
  206. }
  207. for (var i = 0; i < videos.length; i++) {
  208. var vparts = videos[i].getElementsByTagName('Part');
  209. if (vparts.length > 0) {
  210. file = vparts[0].attributes['file'].value;
  211. id = vparts[0].attributes['id'].value;
  212. if (videos[i].attributes['lastViewedAt'] === null || videos[i].attributes['lastViewedAt'] === undefined) {
  213. break;
  214. }
  215. }
  216. }
  217.  
  218. if (file !== null) {
  219. openItemOnAgent(file, id, openFolder, serverId).catch(function () {
  220. showToast('Failed to connect to agent, is it running or firewalled?', 1);
  221. });
  222. }
  223. }).catch(function () {
  224. showToast('Failed to get information for directory', 1);
  225. });
  226. }
  227. }
  228. }, function (error) {
  229. showToast('Error getting metadata from ' + metaDataPath + "Error: " + error, 1);
  230. logMessage('Error ' + JSON.stringify(error));
  231. });
  232. }
  233. };
  234.  
  235. var bindClicks = function () {
  236. var hasBtn = false;
  237. var toolBar = jQuery('button[data-testid="preplay-play"]').parent();
  238. toolBar.children('button').each(function (i, e) {
  239. if (jQuery(e).hasClass('plexextplayer'))
  240. hasBtn = true;
  241. });
  242.  
  243.  
  244. if (!hasBtn) {
  245. var template = jQuery('<button class="play-btn media-poster-btn btn-link plexextplayer" tabindex="-1" title="Play Externally"><i class="glyphicon play plexextplayer plexextplayerico"></i></button><button class="play-btn media-poster-btn btn-link plexextplayer" title="Open folder" tabindex="-1"><i data-type="folder" title="Open folder" class="glyphicon play plexextplayer plexfolderextplayerico"></i></button>');
  246. toolBar.prepend(template);
  247. template.click(clickListener);
  248.  
  249. }
  250.  
  251. // Cover page
  252. jQuery('[class^=MetadataPosterCardOverlay-link]').each(function (i, e) {
  253. e = jQuery(e);
  254. let poster = e.parent();
  255. if (poster.length === 1 && poster[0].className.trim().startsWith('MetadataPosterCardOverlay')) {
  256. let existingButton = poster.find('.plexextplayerico');
  257. if (existingButton.length === 0) {
  258. let url = poster.find('a').attr('href');
  259. let template = jQuery('<a href="' + url + '" aria-haspopup="false" aria-role="button" class="" type="button"><i class="glyphicon play plexextplayer plexextplayerico plexextplayericocover"></i></button>');
  260. let newButton = template.appendTo(poster);
  261. newButton.click(clickListener);
  262. poster.mouseenter(function () {
  263. newButton.find('i').css('display', 'block');
  264. });
  265. poster.mouseleave(function () {
  266. newButton.find('i').css('display', 'none');
  267. });
  268. }
  269. }
  270. });
  271.  
  272. //Thumbnails
  273. jQuery('[class^=PosterCardLink-link]').each(function (i, e) {
  274. e = jQuery(e);
  275. let thumb = e.parent();
  276. if (thumb.length === 1 && thumb[0].className.trim().startsWith('MetadataPosterListItem-card')) {
  277. let existingButton = thumb.find('.plexextplayerico');
  278. if (existingButton.length === 0) {
  279. let url = thumb.find('a').attr('href');
  280. let template = jQuery('<a href="' + url + '" aria-haspopup="false" aria-role="button" class="" type="button"><i class="glyphicon play plexextplayer plexextplayerico plexextplayericocover"></i></button>');
  281. let thumbButton = template.appendTo(thumb);
  282. thumbButton.click(clickListener);
  283. thumb.mouseenter(function () {
  284. thumbButton.find('i').css('display', 'block');
  285. });
  286. thumb.mouseleave(function () {
  287. thumbButton.find('i').css('display', 'none');
  288. });
  289. }
  290. }
  291. });
  292.  
  293. // Playlist
  294. jQuery("span[class^=' MetadataPosterTitle-singleLineTitle']").each(function (i, e) {
  295. e = jQuery(e);
  296. let playlistItem = e.closest("[class^='PlaylistItemRow-overlay']").find("[class^='PlaylistItemMetadata-indexContainer']");
  297. if (playlistItem.length === 1) {
  298. let existingButton = playlistItem.find('.plexextplayerico');
  299. if (existingButton.length === 0) {
  300. let url = e.find('a').attr('href');
  301. let template = jQuery('<a href="' + url + '" aria-haspopup="false" aria-role="button" class="" type="button"><i class="glyphicon play plexextplayer plexextplayerico plexextplayericocover"></i></button>');
  302. let newButton = template.appendTo(playlistItem);
  303. newButton.click(clickListener);
  304. playlistItem.closest("[class^='PlaylistItemRow-overlay']").mouseenter(function () {
  305. newButton.find('i').css('display', 'block');
  306. });
  307. playlistItem.closest("[class^='PlaylistItemRow-overlay']").mouseleave(function () {
  308. newButton.find('i').css('display', 'none');
  309. });
  310. }
  311. }
  312. });
  313. };
  314.  
  315. var getHosts = function () {
  316. makeRequest('https://plex.tv/api/resources?includeHttps=1&X-Plex-Token=' + localStorage.myPlexAccessToken)
  317. .then(function (response) {
  318. let parts = response.responseXML.getElementsByTagName('Device');
  319. for (let i = 0; i < parts.length; i++) {
  320. if (parts[i].getAttribute('product') == 'Plex Media Server') {
  321. let connections = parts[i].getElementsByTagName('Connection');
  322. for (let j = 0; j < connections.length; j++) {
  323. if (connections[j].getAttribute('local') == parts[i].getAttribute('publicAddressMatches')) {
  324. pmsUrls.set(parts[i].getAttribute('clientIdentifier'), 'http://' + connections[j].getAttribute('address') + ':' + connections[j].getAttribute('port'));
  325. break;
  326. }
  327. }
  328. }
  329. }
  330. }).catch(function () {
  331. showToast('Failed to get PMS URLs', 1);
  332. });
  333. }
  334.  
  335. // Make buttons smaller
  336. jQuery('body').append('<style>.plexextplayericocover {right: 10px; top: 10px; position:absolute; display:none;font-size:15px;} .glyphicon.plexfolderextplayerico:before { content: "\\e145"; } .glyphicon.plexextplayerico:before { content: "\\e161"; }</style>');
  337.  
  338. //Get known Plex Servers
  339. var pmsUrls = new Map();
  340. setTimeout(function () {
  341. getHosts();
  342. console.log(pmsUrls);
  343. }, 1000);
  344.  
  345. // Bind buttons and check for new ones every 100ms
  346. setInterval(bindClicks, 100);
  347. bindClicks();