embyToLocalPlayer

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

目前為 2024-05-18 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name embyToLocalPlayer
  3. // @name:zh-CN embyToLocalPlayer
  4. // @name:en embyToLocalPlayer
  5. // @namespace https://github.com/kjtsune/embyToLocalPlayer
  6. // @version 2024.05.15
  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. // @run-at document-start
  23. // @connect 127.0.0.1
  24. // @license MIT
  25. // ==/UserScript==
  26. 'use strict';
  27. /*
  28. 2024.03.27:
  29. 1. 预重定向下一集视频流 url (配置文件有新增条目 [dev])
  30. * 版本间累积更新:
  31. * 可播放前检查视频流重定向。
  32. * 新版 mpv 播放列表报错。@verygoodlee
  33. * 修了些回传失败的情况。
  34. * 适配 Emby 全部播放/随机播放/播放列表 (油猴也需要更新,限电影和音乐视频类型)
  35. 2024-1-2:
  36. 1. 适配 Emby 跳过简介/片头。(限 mpv,且视频本身无章节,通过添加章节实现。)
  37. * 版本间累积更新:
  38. * mpv script-opts 被覆盖。@verygoodlee
  39. * mpv 切回第一集时网络外挂字幕丢失。@verygoodlee
  40. 2023-12-11:
  41. 1. 美化 mpv pot 标题。
  42. 2. 改善版本筛选逻辑。
  43. */
  44. (function () {
  45. 'use strict';
  46. let _crackFullPath = Boolean(localStorage.getItem('crackFullPath'));
  47. let _disableOpenFolder = Boolean(localStorage.getItem('disableOpenFolder'));
  48. let fistTime = true;
  49. let config = {
  50. logLevel: 2,
  51. disableOpenFolder: _disableOpenFolder, // _disableOpenFolder 改为 true 则禁用打开文件夹的按钮。
  52. crackFullPath: _crackFullPath,
  53. };
  54.  
  55. let logger = {
  56. error: function (...args) {
  57. if (config.logLevel >= 1) {
  58. console.log('%cerror', 'color: yellow; font-style: italic; background-color: blue;', ...args);
  59. }
  60. },
  61. info: function (...args) {
  62. if (config.logLevel >= 2) {
  63. console.log('%cinfo', 'color: yellow; font-style: italic; background-color: blue;', ...args);
  64. }
  65. },
  66. debug: function (...args) {
  67. if (config.logLevel >= 3) {
  68. console.log('%cdebug', 'color: yellow; font-style: italic; background-color: blue;', ...args);
  69. }
  70. },
  71. }
  72.  
  73. async function sleep(ms) {
  74. return new Promise(resolve => setTimeout(resolve, ms));
  75. }
  76.  
  77. function removeErrorWindows() {
  78. let okButtonList = document.querySelectorAll('button[data-id="ok"]');
  79. let state = false;
  80. for (let index = 0; index < okButtonList.length; index++) {
  81. const element = okButtonList[index];
  82. if (element.textContent.search(/(了解|好的|知道|Got It)/) != -1) {
  83. element.click();
  84. state = true;
  85. }
  86. }
  87.  
  88. let jellyfinSpinner = document.querySelector('div.docspinner');
  89. if (jellyfinSpinner) {
  90. jellyfinSpinner.remove();
  91. state = true;
  92. };
  93.  
  94. return state;
  95. }
  96.  
  97. function switchLocalStorage(key, defaultValue = 'true', trueValue = 'true', falseValue = 'false') {
  98. if (key in localStorage) {
  99. let value = (localStorage.getItem(key) === trueValue) ? falseValue : trueValue;
  100. localStorage.setItem(key, value);
  101. } else {
  102. localStorage.setItem(key, defaultValue)
  103. }
  104. logger.info('switchLocalStorage ', key, ' to ', localStorage.getItem(key));
  105. }
  106.  
  107. function setModeSwitchMenu(storageKey, menuStart = '', menuEnd = '', defaultValue = '关闭', trueValue = '开启', falseValue = '关闭') {
  108. let switchNameMap = { 'true': trueValue, 'false': falseValue, null: defaultValue };
  109. let menuId = GM_registerMenuCommand(menuStart + switchNameMap[localStorage.getItem(storageKey)] + menuEnd, clickMenu);
  110.  
  111. function clickMenu() {
  112. GM_unregisterMenuCommand(menuId);
  113. switchLocalStorage(storageKey)
  114. menuId = GM_registerMenuCommand(menuStart + switchNameMap[localStorage.getItem(storageKey)] + menuEnd, clickMenu);
  115. }
  116.  
  117. }
  118.  
  119. function sendDataToLocalServer(data, path) {
  120. let url = `http://127.0.0.1:58000/${path}/`;
  121. GM_xmlhttpRequest({
  122. method: 'POST',
  123. url: url,
  124. data: JSON.stringify(data),
  125. headers: {
  126. 'Content-Type': 'application/json'
  127. },
  128. });
  129. }
  130.  
  131. async function removeErrorWindowsMultiTimes() {
  132. for (const times of Array(15).keys()) {
  133. await sleep(200);
  134. if (removeErrorWindows()) {
  135. logger.info(`remove error window used time: ${(times + 1) * 0.2}`);
  136. break;
  137. };
  138. }
  139. }
  140.  
  141. async function embyToLocalPlayer(playbackUrl, request, playbackData, extraData) {
  142. let data = {
  143. ApiClient: ApiClient,
  144. playbackData: playbackData,
  145. playbackUrl: playbackUrl,
  146. request: request,
  147. mountDiskEnable: localStorage.getItem('mountDiskEnable'),
  148. extraData: extraData,
  149. fistTime: fistTime,
  150. };
  151. sendDataToLocalServer(data, 'embyToLocalPlayer');
  152. removeErrorWindowsMultiTimes();
  153. fistTime = false;
  154. }
  155.  
  156. function isHidden(el) {
  157. return (el.offsetParent === null);
  158. }
  159.  
  160. function getVisibleElement(elList) {
  161. if (!elList) return;
  162. if (NodeList.prototype.isPrototypeOf(elList)) {
  163. for (let i = 0; i < elList.length; i++) {
  164. if (!isHidden(elList[i])) {
  165. return elList[i];
  166. }
  167. }
  168. } else {
  169. return elList;
  170. }
  171. }
  172.  
  173. async function addOpenFolderElement() {
  174. if (config.disableOpenFolder) return;
  175. let mediaSources = null;
  176. for (const _ of Array(5).keys()) {
  177. await sleep(500);
  178. mediaSources = getVisibleElement(document.querySelectorAll('div.mediaSources'));
  179. if (mediaSources) break;
  180. }
  181. if (!mediaSources) return;
  182. let pathDiv = mediaSources.querySelector('div[class^="sectionTitle sectionTitle-cards"] > div');
  183. if (!pathDiv || pathDiv.className == 'mediaInfoItems' || pathDiv.id == 'addFileNameElement') return;
  184. let full_path = pathDiv.textContent;
  185. if (!full_path.match(/[/:]/)) return;
  186. if (full_path.match(/\d{1,3}\.?\d{0,2} (MB|GB)/)) return;
  187.  
  188. let openButtonHtml = `<a id="openFolderButton" is="emby-linkbutton" class="raised item-tag-button
  189. nobackdropfilter emby-button" ><i class="md-icon button-icon button-icon-left">link</i>Open Folder</a>`
  190. pathDiv.insertAdjacentHTML('beforebegin', openButtonHtml);
  191. let btn = mediaSources.querySelector('a#openFolderButton');
  192. btn.addEventListener("click", () => {
  193. logger.info(full_path);
  194. sendDataToLocalServer({ full_path: full_path }, 'openFolder');
  195. });
  196. }
  197.  
  198. async function addFileNameElement(url, request) {
  199. let mediaSources = null;
  200. for (const _ of Array(5).keys()) {
  201. await sleep(500);
  202. mediaSources = getVisibleElement(document.querySelectorAll('div.mediaSources'));
  203. if (mediaSources) break;
  204. }
  205. if (!mediaSources) return;
  206. let pathDivs = mediaSources.querySelectorAll('div[class^="sectionTitle sectionTitle-cards"] > div');
  207. if (!pathDivs) return;
  208. pathDivs = Array.from(pathDivs);
  209. let _pathDiv = pathDivs[0];
  210. if (!/\d{4}\/\d+\/\d+/.test(_pathDiv.textContent)) return;
  211. if (_pathDiv.id == 'addFileNameElement') return;
  212.  
  213. let response = await originFetch(url, request);
  214. let data = await response.json();
  215. data = data.MediaSources;
  216.  
  217. for (let index = 0; index < pathDivs.length; index++) {
  218. const pathDiv = pathDivs[index];
  219. let filePath = data[index].Path;
  220. let fileName = filePath.split('\\').pop().split('/').pop();
  221. fileName = (config.crackFullPath) ? filePath : fileName;
  222. let fileDiv = `<div id="addFileNameElement">${fileName}</div> `
  223. pathDiv.insertAdjacentHTML('beforebegin', fileDiv);
  224. }
  225. }
  226.  
  227.  
  228. let serverName = null;
  229. let episodesInfoCache = []; // ['type:[Episodes|NextUp|Items]', resp]
  230. let episodesInfoRe = /\/Episodes\?IsVirtual|\/NextUp\?Series|\/Items\?ParentId=\w+&Filters=IsNotFolder&Recursive=true/; // Items已排除播放列表
  231. // 点击位置:Episodes 继续观看,如果是即将观看,可能只有一集的信息 | NextUp 新播放或媒体库播放 | Items 季播放。 只有 Episodes 返回所有集的数据。
  232. let playlistInfoCache = null;
  233.  
  234. const originFetch = fetch;
  235. unsafeWindow.fetch = async (url, request) => {
  236. if (serverName === null) {
  237. serverName = typeof ApiClient === 'undefined' ? null : ApiClient._appName.split(' ')[0].toLowerCase();
  238. }
  239. // 适配播放列表及媒体库的全部播放、随机播放。限电影及音乐视频。排除 Jellyfin
  240. if (url.includes('Items?') && url.includes('emby') && (url.includes('Limit=300') || url.includes('Limit=1000'))) {
  241. let _resp = await originFetch(url, request);
  242. await ApiClient._userViewsPromise.then(result => {
  243. let viewsItems = result.Items;
  244. let viewsIds = [];
  245. viewsItems.forEach(item => {
  246. viewsIds.push(item.Id);
  247. });
  248. let viewsRegex = viewsIds.join('|');
  249. viewsRegex = `ParentId=(${viewsRegex})`
  250. if (!RegExp(viewsRegex).test(url)) { // 美化标题所需,并非播放列表。
  251. episodesInfoCache = ['Items', _resp.clone()]
  252. logger.info(episodesInfoCache);
  253. console.log(viewsRegex);
  254. return _resp;
  255. }
  256. }).catch(error => {
  257. console.error("Error occurred: ", error);
  258. });
  259. playlistInfoCache = null;
  260. let _resd = await _resp.clone().json();
  261. if (['Movie', 'MusicVideo'].includes(_resd.Items[0].Type)) {
  262. playlistInfoCache = _resd
  263. }
  264. return _resp
  265. }
  266. // 获取各集标题等,仅用于美化标题,放后面避免误拦截首页右键媒体库随机播放数据。
  267. let _epMatch = url.match(episodesInfoRe);
  268. if (_epMatch) {
  269. _epMatch = _epMatch[0].split(['?'])[0].substring(1); // Episodes|NextUp|Items
  270. let _resp = await originFetch(url, request);
  271. episodesInfoCache = [_epMatch, _resp.clone()]
  272. logger.info(episodesInfoCache)
  273. return _resp
  274. }
  275. try {
  276. if (url.indexOf('/PlaybackInfo?UserId') != -1) {
  277. if (url.indexOf('IsPlayback=true') != -1 && localStorage.getItem('webPlayerEnable') != 'true') {
  278. let match = url.match(/\/Items\/(\w+)\/PlaybackInfo/);
  279. let itemId = match ? match[1] : null;
  280. let userId = ApiClient._serverInfo.UserId;
  281. let [playbackResp, mainEpInfo] = await Promise.all([
  282. originFetch(url, request),
  283. ApiClient.getItem(userId, itemId),
  284. ]);
  285. let playbackData = await playbackResp.clone().json();
  286. let episodesInfoData = episodesInfoCache[0] ? await episodesInfoCache[1].clone().json() : null;
  287. episodesInfoData = episodesInfoData ? episodesInfoData.Items : null;
  288. let playlistData = playlistInfoCache ? playlistInfoCache.Items : null;
  289. episodesInfoCache = []
  290. let extraData = {
  291. mainEpInfo: mainEpInfo,
  292. episodesInfo: episodesInfoData,
  293. playlistInfo: playlistData,
  294. gmInfo: GM_info,
  295. userAgent: navigator.userAgent,
  296. }
  297. playlistInfoCache = null;
  298. logger.info(extraData);
  299. if (playbackData.MediaSources[0].Path.search(/\Wbackdrop/i) == -1) {
  300. embyToLocalPlayer(url, request, playbackData, extraData);
  301. return
  302. }
  303. } else {
  304. addOpenFolderElement();
  305. addFileNameElement(url, request);
  306. }
  307. } else if (url.indexOf('/Playing/Stopped') != -1 && localStorage.getItem('webPlayerEnable') != 'true') {
  308. return
  309. }
  310. } catch (error) {
  311. logger.error(error);
  312. removeErrorWindowsMultiTimes();
  313. return
  314. }
  315. return originFetch(url, request);
  316. }
  317.  
  318. function initXMLHttpRequest() {
  319. const open = XMLHttpRequest.prototype.open;
  320. XMLHttpRequest.prototype.open = function (...args) {
  321. let url = args[1]
  322. if (serverName === null && url.indexOf('X-Plex-Product') != -1) { serverName = 'plex' };
  323. // 正常请求不匹配的网址
  324. if (url.indexOf('playQueues?type=video') == -1) {
  325. return open.apply(this, args);
  326. }
  327. // 请求前拦截
  328. if (url.indexOf('playQueues?type=video') != -1
  329. && localStorage.getItem('webPlayerEnable') != 'true') {
  330. fetch(url, {
  331. method: args[0],
  332. headers: {
  333. 'Accept': 'application/json',
  334. }
  335. })
  336. .then(response => response.json())
  337. .then((res) => {
  338. let data = {
  339. playbackData: res,
  340. playbackUrl: url,
  341. mountDiskEnable: localStorage.getItem('mountDiskEnable'),
  342.  
  343. };
  344. sendDataToLocalServer(data, 'plexToLocalPlayer');
  345. });
  346. return;
  347. }
  348. return open.apply(this, args);
  349. }
  350. }
  351.  
  352. // 初始化请求并拦截 plex
  353. initXMLHttpRequest()
  354.  
  355. setModeSwitchMenu('webPlayerEnable', '脚本在当前服务器 已', '', '启用', '禁用', '启用')
  356. setModeSwitchMenu('mountDiskEnable', '读取硬盘模式已经 ')
  357. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址