Greasy Fork is available in English.

embyToLocalPlayer

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

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