embyToLocalPlayer

Emby/Jellyfin 调用外部本地播放器,并回传播放记录。适配 Plex。

当前为 2025-05-21 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name embyToLocalPlayer
  3. // @name:zh-CN embyToLocalPlayer
  4. // @name:en embyToLocalPlayer
  5. // @namespace https://github.com/kjtsune/embyToLocalPlayer
  6. // @version 2025.05.09
  7. // @description Emby/Jellyfin 调用外部本地播放器,并回传播放记录。适配 Plex。
  8. // @description:zh-CN Emby/Jellyfin 调用外部本地播放器,并回传播放记录。适配 Plex。
  9. // @description:en Play in an external player. Update watch history to Emby/Jellyfin server. Support Plex.
  10. // @author Kjtsune
  11. // @match *://*/web/index.html*
  12. // @match *://*/*/web/index.html*
  13. // @match *://*/web/
  14. // @match *://*/*/web/
  15. // @match https://app.emby.media/*
  16. // @match https://app.plex.tv/*
  17. // @icon https://www.google.com/s2/favicons?sz=64&domain=emby.media
  18. // @grant unsafeWindow
  19. // @grant GM_info
  20. // @grant GM_xmlhttpRequest
  21. // @grant GM_registerMenuCommand
  22. // @grant GM_unregisterMenuCommand
  23. // @grant GM_getValue
  24. // @grant GM_setValue
  25. // @grant GM_deleteValue
  26. // @run-at document-start
  27. // @connect 127.0.0.1
  28. // @license MIT
  29. // ==/UserScript==
  30. 'use strict';
  31. /*global ApiClient*/
  32.  
  33. (function () {
  34. 'use strict';
  35. let fistTime = true;
  36. let config = {
  37. logLevel: 2,
  38. disableOpenFolder: undefined, // undefined 改为 true 则禁用打开文件夹的按钮。
  39. crackFullPath: undefined,
  40. };
  41.  
  42. const originFetch = fetch;
  43.  
  44. let logger = {
  45. error: function (...args) {
  46. if (config.logLevel >= 1) {
  47. console.log('%cerror', 'color: yellow; font-style: italic; background-color: blue;', ...args);
  48. }
  49. },
  50. info: function (...args) {
  51. if (config.logLevel >= 2) {
  52. console.log('%cinfo', 'color: yellow; font-style: italic; background-color: blue;', ...args);
  53. }
  54. },
  55. debug: function (...args) {
  56. if (config.logLevel >= 3) {
  57. console.log('%cdebug', 'color: yellow; font-style: italic; background-color: blue;', ...args);
  58. }
  59. },
  60. }
  61.  
  62. function myBool(value) {
  63. if (Array.isArray(value) && value.length === 0) return false;
  64. if (value !== null && typeof value === 'object' && Object.keys(value).length === 0) return false;
  65. return Boolean(value);
  66. }
  67.  
  68. async function sleep(ms) {
  69. return new Promise(resolve => setTimeout(resolve, ms));
  70. }
  71.  
  72. function isHidden(el) {
  73. return (el.offsetParent === null);
  74. }
  75.  
  76. function getVisibleElement(elList) {
  77. if (!elList) return;
  78. if (Object.prototype.isPrototypeOf.call(NodeList.prototype, elList)) {
  79. for (let i = 0; i < elList.length; i++) {
  80. if (!isHidden(elList[i])) {
  81. return elList[i];
  82. }
  83. }
  84. } else {
  85. return elList;
  86. }
  87. }
  88.  
  89. function _init_config_main() {
  90. function _init_config_by_key(confKey) {
  91. let confLocal = localStorage.getItem(confKey);
  92. if (confLocal == null) return;
  93. if (confLocal == 'true') {
  94. GM_setValue(confKey, true);
  95.  
  96. } else if (confLocal == 'false') {
  97. GM_setValue(confKey, false);
  98. }
  99. let confGM = GM_getValue(confKey, null);
  100. if (confGM !== null) { config[confKey] = confGM };
  101. }
  102. _init_config_by_key('crackFullPath');
  103. }
  104.  
  105. function switchLocalStorage(key, defaultValue = 'true', trueValue = 'true', falseValue = 'false') {
  106. if (key in localStorage) {
  107. let value = (localStorage.getItem(key) === trueValue) ? falseValue : trueValue;
  108. localStorage.setItem(key, value);
  109. } else {
  110. localStorage.setItem(key, defaultValue)
  111. }
  112. logger.info('switchLocalStorage ', key, ' to ', localStorage.getItem(key));
  113. }
  114.  
  115. function setModeSwitchMenu(storageKey, menuStart = '', menuEnd = '', defaultValue = '关闭', trueValue = '开启', falseValue = '关闭') {
  116. let switchNameMap = { 'true': trueValue, 'false': falseValue, null: defaultValue };
  117. let menuId = GM_registerMenuCommand(menuStart + switchNameMap[localStorage.getItem(storageKey)] + menuEnd, clickMenu);
  118.  
  119. function clickMenu() {
  120. GM_unregisterMenuCommand(menuId);
  121. switchLocalStorage(storageKey)
  122. menuId = GM_registerMenuCommand(menuStart + switchNameMap[localStorage.getItem(storageKey)] + menuEnd, clickMenu);
  123. }
  124.  
  125. }
  126.  
  127. function removeErrorWindows() {
  128. let okButtonList = document.querySelectorAll('button[data-id="ok"]');
  129. let state = false;
  130. for (let index = 0; index < okButtonList.length; index++) {
  131. const element = okButtonList[index];
  132. if (element.textContent.search(/(了解|好的|知道|Got It)/) != -1) {
  133. element.click();
  134. if (isHidden(element)) { continue; }
  135. state = true;
  136. }
  137. }
  138.  
  139. let jellyfinSpinner = document.querySelector('div.docspinner');
  140. if (jellyfinSpinner) {
  141. jellyfinSpinner.remove();
  142. state = true;
  143. };
  144.  
  145. return state;
  146. }
  147.  
  148. async function removeErrorWindowsMultiTimes() {
  149. for (const times of Array(15).keys()) {
  150. await sleep(200);
  151. if (removeErrorWindows()) {
  152. logger.info(`remove error window used time: ${(times + 1) * 0.2}`);
  153. break;
  154. };
  155. }
  156. }
  157.  
  158. function sendDataToLocalServer(data, path) {
  159. let url = `http://127.0.0.1:58000/${path}/`;
  160. GM_xmlhttpRequest({
  161. method: 'POST',
  162. url: url,
  163. data: JSON.stringify(data),
  164. headers: {
  165. 'Content-Type': 'application/json'
  166. },
  167. onerror: function (error) {
  168. alert(`${url}\n请求错误,本地服务未运行,请查看使用说明。\nhttps://github.com/kjtsune/embyToLocalPlayer`);
  169. console.error('请求错误:', error);
  170. }
  171. });
  172. logger.info(path, data);
  173. }
  174.  
  175. let serverName = null;
  176. let episodesInfoCache = []; // ['type:[Episodes|NextUp|Items]', resp]
  177. let episodesInfoRe = /\/Episodes\?IsVirtual|\/NextUp\?Series|\/Items\?ParentId=\w+&Filters=IsNotFolder&Recursive=true/; // Items已排除播放列表
  178. // 点击位置:Episodes 继续观看,如果是即将观看,可能只有一集的信息 | NextUp 新播放或媒体库播放 | Items 季播放。 只有 Episodes 返回所有集的数据。
  179. let playlistInfoCache = null;
  180. let resumeRawInfoCache = null;
  181. let resumePlaybakCache = {};
  182. let resumeItemDataCache = {};
  183. let allPlaybackCache = {};
  184. let allItemDataCache = {};
  185.  
  186. let metadataChangeRe = /\/MetadataEditor|\/Refresh\?/;
  187. let metadataMayChange = false;
  188.  
  189. function cleanOptionalCache() {
  190. resumeRawInfoCache = null;
  191. resumePlaybakCache = {};
  192. resumeItemDataCache = {};
  193. allPlaybackCache = {};
  194. allItemDataCache = {};
  195. episodesInfoCache = []
  196. }
  197.  
  198. function throttle(fn, delay) {
  199. let lastTime = 0;
  200. return function (...args) {
  201. const now = Date.now();
  202. if (now - lastTime >= delay) {
  203. lastTime = now;
  204. fn.apply(this, args);
  205. }
  206. };
  207. }
  208.  
  209. let addOpenFolderElement = throttle(_addOpenFolderElement, 100);
  210.  
  211. async function _addOpenFolderElement(itemId) {
  212. if (config.disableOpenFolder) return;
  213. let mediaSources = null;
  214. for (const _ of Array(5).keys()) {
  215. await sleep(500);
  216. mediaSources = getVisibleElement(document.querySelectorAll('div.mediaSources'));
  217. if (mediaSources) break;
  218. }
  219. if (!mediaSources) return;
  220. let pathDiv = mediaSources.querySelector('div[class^="sectionTitle sectionTitle-cards"] > div');
  221. if (!pathDiv || pathDiv.className == 'mediaInfoItems' || pathDiv.id == 'addFileNameElement') return;
  222. let full_path = pathDiv.textContent;
  223. if (!full_path.match(/[/:]/)) return;
  224. if (full_path.match(/\d{1,3}\.?\d{0,2} (MB|GB)/)) return;
  225.  
  226. let itemData = (itemId in allItemDataCache) ? allItemDataCache[itemId] : null
  227. let strmFile = (full_path.startsWith('http')) ? itemData?.Path : null
  228.  
  229. let openButtonHtml = `<a id="openFolderButton" is="emby-linkbutton" class="raised item-tag-button
  230. nobackdropfilter emby-button" ><i class="md-icon button-icon button-icon-left">link</i>Open Folder</a>`
  231. pathDiv.insertAdjacentHTML('beforebegin', openButtonHtml);
  232. let btn = mediaSources.querySelector('a#openFolderButton');
  233. if (strmFile) {
  234. pathDiv.innerHTML = pathDiv.innerHTML + '<br>' + strmFile;
  235. full_path = strmFile; // emby 会把 strm 内的链接当路径展示
  236. }
  237. btn.addEventListener('click', () => {
  238. logger.info(full_path);
  239. sendDataToLocalServer({ full_path: full_path }, 'openFolder');
  240. });
  241. }
  242.  
  243. async function addFileNameElement(resp) {
  244. let mediaSources = null;
  245. for (const _ of Array(5).keys()) {
  246. await sleep(500);
  247. mediaSources = getVisibleElement(document.querySelectorAll('div.mediaSources'));
  248. if (mediaSources) break;
  249. }
  250. if (!mediaSources) return;
  251. let pathDivs = mediaSources.querySelectorAll('div[class^="sectionTitle sectionTitle-cards"] > div');
  252. if (!pathDivs) return;
  253. pathDivs = Array.from(pathDivs);
  254. let _pathDiv = pathDivs[0];
  255. if (_pathDiv.id == 'addFileNameElement') return;
  256. let isAdmin = !/\d{4}\/\d+\/\d+/.test(_pathDiv.textContent); // 非管理员只有包含添加日期的文件类型 div
  257. let isStrm = _pathDiv.textContent.startsWith('http');
  258. if (isAdmin) {
  259. if (!isStrm) { return; }
  260. pathDivs = pathDivs.filter((_, index) => index % 2 === 0); // 管理员一个文件同时有路径和文件类型两个 div
  261. }
  262.  
  263. let sources = await resp.clone().json();
  264. sources = sources.MediaSources;
  265. for (let index = 0; index < pathDivs.length; index++) {
  266. const pathDiv = pathDivs[index];
  267. let fileName = sources[index].Name; // 多版本的话,是版本名。
  268. let filePath = sources[index].Path;
  269. let strmFile = filePath.startsWith('http');
  270. if (!strmFile) {
  271. fileName = filePath.split('\\').pop().split('/').pop();
  272. fileName = (config.crackFullPath && !isAdmin) ? filePath : fileName;
  273. }
  274. let fileDiv = `<div id="addFileNameElement">${fileName}</div> `
  275. if (strmFile && (!isAdmin && config.crackFullPath)) {
  276. fileDiv = `<div id="addFileNameElement">${fileName}<br>${filePath}</div> `
  277. }
  278. pathDiv.insertAdjacentHTML('beforebegin', fileDiv);
  279. }
  280. }
  281.  
  282. function makeItemIdCorrect(itemId) {
  283. if (serverName !== 'emby') { return itemId; }
  284. if (!resumeRawInfoCache || !episodesInfoCache) { return itemId; }
  285. let resumeIds = resumeRawInfoCache.map(item => item.Id);
  286. if (resumeIds.includes(itemId)) { return itemId; }
  287. let pageId = window.location.href.match(/\/item\?id=(\d+)/)?.[1];
  288. if (resumeIds.includes(pageId) && itemId == episodesInfoCache[0].Id) {
  289. // 解决从继续观看进入集详情页时,并非播放第一集,却请求首集视频文件信息导致无法播放。
  290. // 手动解决方法:从下方集卡片点击播放,或从集卡片再次进入集详情页后播放。
  291. // 本函数的副作用:集详情页底部的第一集卡片点播放按钮会播放当前集。
  292. // 副作用解决办法:再点击一次,或者点第一集卡片进入详情页后再播放。不过一般也不怎么会回头看第一集。
  293. return pageId;
  294.  
  295. } else if (window.location.href.match(/serverId=/)) {
  296. return itemId; // 仅处理首页继续观看和集详情页,其他页面忽略。
  297. }
  298. let correctSeaId = episodesInfoCache.find(item => item.Id == itemId)?.SeasonId;
  299. let correctItemId = resumeRawInfoCache.find(item => item.SeasonId == correctSeaId)?.Id;
  300. if (correctSeaId && correctItemId) {
  301. logger.info(`makeItemIdCorrect, old=${itemId}, new=${correctItemId}`)
  302. return correctItemId;
  303. }
  304. return itemId;
  305. }
  306.  
  307. async function embyToLocalPlayer(playbackUrl, request, playbackData, extraData) {
  308. let data = {
  309. ApiClient: ApiClient,
  310. playbackData: playbackData,
  311. playbackUrl: playbackUrl,
  312. request: request,
  313. mountDiskEnable: localStorage.getItem('mountDiskEnable'),
  314. extraData: extraData,
  315. fistTime: fistTime,
  316. };
  317. sendDataToLocalServer(data, 'embyToLocalPlayer');
  318. removeErrorWindowsMultiTimes();
  319. fistTime = false;
  320. }
  321.  
  322. async function apiClientGetWithCache(itemId, cacheList, funName) {
  323. for (const cache of cacheList) {
  324. if (itemId in cache) {
  325. logger.info(`HIT ${funName} itemId=${itemId}`)
  326. return cache[itemId];
  327. }
  328. }
  329. logger.info(`MISS ${funName} itemId=${itemId}`)
  330. let resInfo;
  331. switch (funName) {
  332. case 'getPlaybackInfo':
  333. resInfo = await ApiClient.getPlaybackInfo(itemId);
  334. break;
  335. case 'getItem':
  336. resInfo = await ApiClient.getItem(ApiClient._serverInfo.UserId, itemId);
  337. break;
  338. default:
  339. break;
  340. }
  341. for (const cache of cacheList) {
  342. if (funName == 'getPlaybackInfo') {
  343. // strm ffprobe 处理前后的外挂字幕 index 会变化,故不缓存。
  344. let runtime = resInfo?.MediaSources?.[0]?.RunTimeTicks;
  345. if (!runtime)
  346. break;
  347. }
  348. cache[itemId] = resInfo;
  349. }
  350. return resInfo;
  351. }
  352.  
  353. async function getPlaybackWithCace(itemId) {
  354. return apiClientGetWithCache(itemId, [resumePlaybakCache, allPlaybackCache], 'getPlaybackInfo');
  355. }
  356.  
  357. async function getItemInfoWithCace(itemId) {
  358. return apiClientGetWithCache(itemId, [resumeItemDataCache, allItemDataCache], 'getItem');
  359. }
  360.  
  361. async function dealWithPlaybakInfo(raw_url, url, options) {
  362. console.time('dealWithPlaybakInfo');
  363. let rawId = url.match(/\/Items\/(\w+)\/PlaybackInfo/)[1];
  364. episodesInfoCache = episodesInfoCache[0] ? episodesInfoCache[1].clone() : null;
  365. let itemId = rawId;
  366. let [playbackData, mainEpInfo, episodesInfoData] = await Promise.all([
  367. getPlaybackWithCace(itemId), // originFetch(raw_url, request), 可能会 NoCompatibleStream
  368. getItemInfoWithCace(itemId),
  369. episodesInfoCache?.json(),
  370. ]);
  371. console.timeEnd('dealWithPlaybakInfo');
  372. episodesInfoData = (episodesInfoData && episodesInfoData.Items) ? episodesInfoData.Items : null;
  373. episodesInfoCache = episodesInfoData;
  374. let correctId = makeItemIdCorrect(itemId);
  375. url = url.replace(`/${rawId}/`, `/${correctId}/`)
  376. if (itemId != correctId) {
  377. itemId = correctId;
  378. [playbackData, mainEpInfo] = await Promise.all([
  379. getPlaybackWithCace(itemId),
  380. getItemInfoWithCace(itemId),
  381. ]);
  382. let startPos = mainEpInfo.UserData.PlaybackPositionTicks;
  383. url = url.replace('StartTimeTicks=0', `StartTimeTicks=${startPos}`);
  384. }
  385. let playlistData = (playlistInfoCache && playlistInfoCache.Items) ? playlistInfoCache.Items : null;
  386. episodesInfoCache = []
  387. let extraData = {
  388. mainEpInfo: mainEpInfo,
  389. episodesInfo: episodesInfoData,
  390. playlistInfo: playlistData,
  391. gmInfo: GM_info,
  392. userAgent: navigator.userAgent,
  393. }
  394. playlistInfoCache = null;
  395. // resumeInfoCache = null;
  396. logger.info(extraData);
  397. if (mainEpInfo?.Type == 'Trailer') {
  398. alert('etlp: Does not support Trailers plugin. Please disable it.');
  399. return false;
  400. }
  401. let notBackdrop = Boolean(playbackData.MediaSources[0].Path.search(/\Wbackdrop/i) == -1);
  402. if (notBackdrop) {
  403. let _req = options ? options : raw_url;
  404. embyToLocalPlayer(url, _req, playbackData, extraData);
  405. return true;
  406. }
  407. return false;
  408. }
  409.  
  410. async function cacheResumeItemInfo() {
  411. let inInit = !myBool(resumeRawInfoCache);
  412. let resumeIds;
  413. let storageKey = 'etlpResumeIds'
  414. if (inInit) {
  415. resumeIds = localStorage.getItem(storageKey)
  416. if (resumeIds) {
  417. resumeIds = JSON.parse(resumeIds);
  418. } else {
  419. return
  420. }
  421. } else {
  422. resumeIds = resumeRawInfoCache.slice(0, 5).map(item => item.Id);
  423. localStorage.setItem(storageKey, JSON.stringify(resumeIds));
  424. }
  425.  
  426. for (let [globalCache, getFun] of [[resumePlaybakCache, getPlaybackWithCace], [resumeItemDataCache, getItemInfoWithCace]]) {
  427. let cacheDataAcc = {};
  428. if (myBool(globalCache)) {
  429. cacheDataAcc = globalCache;
  430. resumeIds = resumeIds.filter(id => !(id in globalCache));
  431. if (resumeIds.length == 0) { return; }
  432. }
  433. let itemInfoList = await Promise.all(
  434. resumeIds.map(id => getFun(id))
  435. )
  436. globalCache = itemInfoList.reduce((acc, result, index) => {
  437. acc[resumeIds[index]] = result;
  438. return acc;
  439. }, cacheDataAcc);
  440. }
  441.  
  442. }
  443.  
  444. async function cloneAndCacheFetch(resp, key, cache) {
  445. try {
  446. const data = await resp.clone().json();
  447. cache[key] = data;
  448. return data;
  449. } catch (_error) {
  450. // pass
  451. }
  452. }
  453.  
  454. let itemInfoRe = /Items\/(\w+)\?/;
  455.  
  456. unsafeWindow.fetch = async (url, options) => {
  457. const raw_url = url;
  458. let urlType = typeof url;
  459. if (urlType != 'string') {
  460. url = raw_url.url;
  461. }
  462. if (serverName === null) {
  463. serverName = typeof ApiClient === 'undefined' ? null : ApiClient._appName.split(' ')[0].toLowerCase();
  464. } else {
  465. if (typeof ApiClient != 'undefined' && ApiClient._deviceName != 'embyToLocalPlayer' && localStorage.getItem('webPlayerEnable') != 'true') {
  466. ApiClient._deviceName = 'embyToLocalPlayer'
  467. cacheResumeItemInfo();
  468. }
  469. }
  470. if (metadataMayChange && url.includes('Items')) {
  471. if (url.includes('reqformat') && !url.includes('fields')) {
  472. cleanOptionalCache();
  473. metadataMayChange = false;
  474. logger.info('cleanOptionalCache by metadataMayChange')
  475. }
  476. }
  477. // 适配播放列表及媒体库的全部播放、随机播放。限电影及音乐视频。
  478. if (url.includes('Items?') && (url.includes('Limit=300') || url.includes('Limit=1000'))) {
  479. let _resp = await originFetch(raw_url, options);
  480. if (serverName == 'emby') {
  481. await ApiClient._userViewsPromise?.then(result => {
  482. let viewsItems = result.Items;
  483. let viewsIds = [];
  484. viewsItems.forEach(item => {
  485. viewsIds.push(item.Id);
  486. });
  487. let viewsRegex = viewsIds.join('|');
  488. viewsRegex = `ParentId=(${viewsRegex})`
  489. if (!RegExp(viewsRegex).test(url)) { // 点击季播放美化标题所需,并非媒体库随机播放。
  490. episodesInfoCache = ['Items', _resp.clone()]
  491. logger.info('episodesInfoCache', episodesInfoCache);
  492. logger.info('viewsRegex', viewsRegex);
  493. return _resp;
  494. }
  495. }).catch(error => {
  496. console.error('Error occurred: ', error);
  497. });
  498. }
  499.  
  500. playlistInfoCache = null;
  501. let _resd = await _resp.clone().json();
  502. if (!_resd.Items[0]) {
  503. logger.error('playlist is empty, skip');
  504. return _resp;
  505. }
  506. if (['Movie', 'MusicVideo'].includes(_resd.Items[0].Type)) {
  507. playlistInfoCache = _resd
  508. logger.info('playlistInfoCache', playlistInfoCache);
  509. }
  510. return _resp
  511. }
  512. // 获取各集标题等,仅用于美化标题,放后面避免误拦截首页右键媒体库随机播放数据。
  513. let _epMatch = url.match(episodesInfoRe);
  514. if (_epMatch) {
  515. _epMatch = _epMatch[0].split(['?'])[0].substring(1); // Episodes|NextUp|Items
  516. let _resp = await originFetch(raw_url, options);
  517. episodesInfoCache = [_epMatch, _resp.clone()]
  518. logger.info('episodesInfoCache', episodesInfoCache);
  519. return _resp
  520. }
  521. if (url.includes('Items/Resume') && url.includes('MediaTypes=Video')) {
  522. let _resp = await originFetch(raw_url, options);
  523. let _resd = await _resp.clone().json();
  524. resumeRawInfoCache = _resd.Items;
  525. cacheResumeItemInfo();
  526. logger.info('resumeRawInfoCache', resumeRawInfoCache);
  527. return _resp
  528. }
  529. // 缓存 itemInfo ,可能匹配到 Items/Resume,故放后面。
  530. if (url.match(itemInfoRe)) {
  531. let itemId = url.match(itemInfoRe)[1];
  532. let resp = await originFetch(raw_url, options);
  533. cloneAndCacheFetch(resp, itemId, allItemDataCache);
  534. return resp;
  535. }
  536. try {
  537. if (url.indexOf('/PlaybackInfo?UserId') != -1) {
  538. if (url.indexOf('IsPlayback=true') != -1 && localStorage.getItem('webPlayerEnable') != 'true') {
  539. if (await dealWithPlaybakInfo(raw_url, url, options)) { return; } // Emby
  540. } else {
  541. let itemId = url.match(/\/Items\/(\w+)\/PlaybackInfo/)[1];
  542. let resp = await originFetch(raw_url, options);
  543. addFileNameElement(resp.clone()); // itemId data 不包含多版本的文件信息,故用不到
  544. addOpenFolderElement(itemId);
  545. cloneAndCacheFetch(resp.clone(), itemId, allPlaybackCache);
  546. return resp;
  547. }
  548. } else if (url.indexOf('/Playing/Stopped') != -1 && localStorage.getItem('webPlayerEnable') != 'true') {
  549. return
  550. }
  551. } catch (error) {
  552. logger.error(error, raw_url, url);
  553. removeErrorWindowsMultiTimes();
  554. return
  555. }
  556.  
  557. if (url.match(metadataChangeRe)) {
  558. if (url.includes('MetadataEditor')) {
  559. metadataMayChange = true;
  560. } else {
  561. cleanOptionalCache();
  562. logger.info('cleanOptionalCache by Refresh')
  563. }
  564. }
  565. return originFetch(raw_url, options);
  566. }
  567.  
  568. function initXMLHttpRequest() {
  569.  
  570. const originOpen = XMLHttpRequest.prototype.open;
  571. const originSend = XMLHttpRequest.prototype.send;
  572. const originSetHeader = XMLHttpRequest.prototype.setRequestHeader;
  573.  
  574. XMLHttpRequest.prototype.setRequestHeader = function (header, value) {
  575. this._headers[header] = value;
  576. return originSetHeader.apply(this, arguments);
  577. }
  578.  
  579. XMLHttpRequest.prototype.open = function (method, url) {
  580. this._method = method;
  581. this._url = url;
  582. this._headers = {};
  583.  
  584. if (serverName === null && this._url.indexOf('X-Plex-Product') != -1) { serverName = 'plex' };
  585. let catchPlex = (serverName == 'plex' && this._url.indexOf('playQueues?type=video') != -1)
  586. if (catchPlex && localStorage.getItem('webPlayerEnable') != 'true') { // Plex
  587. fetch(this._url, {
  588. method: this._method,
  589. headers: {
  590. 'Accept': 'application/json',
  591. }
  592. })
  593. .then(response => response.json())
  594. .then((res) => {
  595. let extraData = {
  596. gmInfo: GM_info,
  597. userAgent: navigator.userAgent,
  598. };
  599. let data = {
  600. playbackData: res,
  601. playbackUrl: this._url,
  602. mountDiskEnable: localStorage.getItem('mountDiskEnable'),
  603. extraData: extraData,
  604. };
  605. sendDataToLocalServer(data, 'plexToLocalPlayer');
  606. });
  607. return;
  608. }
  609. return originOpen.apply(this, arguments);
  610. }
  611.  
  612. XMLHttpRequest.prototype.send = function (body) {
  613.  
  614. let catchJellyfin = (this._method === 'POST' && this._url.endsWith('PlaybackInfo'))
  615. if (catchJellyfin && localStorage.getItem('webPlayerEnable') != 'true') { // Jellyfin
  616. let pbUrl = this._url;
  617. body = JSON.parse(body);
  618. let _body = {};
  619. ['MediaSourceId', 'StartTimeTicks', 'UserId'].forEach(key => {
  620. _body[key] = body[key]
  621. });
  622. let query = new URLSearchParams(_body).toString();
  623. pbUrl = `${pbUrl}?${query}`
  624. let options = {
  625. headers: this._headers,
  626. };
  627. dealWithPlaybakInfo(pbUrl, pbUrl, options);
  628. return;
  629. }
  630. originSend.apply(this, arguments);
  631. }
  632. }
  633.  
  634. initXMLHttpRequest();
  635.  
  636. setModeSwitchMenu('webPlayerEnable', '脚本在当前服务器 已', '', '启用', '禁用', '启用');
  637. setModeSwitchMenu('mountDiskEnable', '读取硬盘模式已经 ');
  638.  
  639. _init_config_main();
  640. })();