embyToLocalPlayer

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

当前为 2023-09-23 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name embyToLocalPlayer
  3. // @name:zh-CN embyToLocalPlayer
  4. // @name:en embyToLocalPlayer
  5. // @namespace https://github.com/kjtsune/embyToLocalPlayer
  6. // @version 1.1.9.1
  7. // @description 需要 Python。Emby 调用外部本地播放器,并回传播放记录。适配 Jellyfin Plex。
  8. // @description:zh-CN 需要 Python。Emby 调用外部本地播放器,并回传播放记录。适配 Jellyfin Plex。
  9. // @description:en Require Python. Play in an external player. Update watch history to emby server. Support Jellyfin Plex.
  10. // @author Kjtsune
  11. // @match *://*/web/index.html*
  12. // @match *://*/*/web/index.html*
  13. // @icon https://www.google.com/s2/favicons?sz=64&domain=emby.media
  14. // @grant unsafeWindow
  15. // @grant GM_xmlhttpRequest
  16. // @grant GM_registerMenuCommand
  17. // @grant GM_unregisterMenuCommand
  18. // @run-at document-start
  19. // @connect 127.0.0.1
  20. // @license MIT
  21. // ==/UserScript==
  22. 'use strict';
  23. /*
  24. 2023-09-04:
  25. 1. Trakt 播放记录单向同步。(详见 FAQ)
  26. 2. 剧集多版本:下一集匹配失败则禁用播放列表。
  27. * 版本间累积更新:
  28. * 自动选择视频版本(限emby,配置文件有新增条目 [dev])
  29. * 油猴:非管理员可显示文件名。
  30.  
  31. 2023-08-09:
  32. 1. 代理配置热更新。
  33. * 版本间累积更新:
  34. * 内封字幕无中文,且未选中字幕时(或无字幕时),尝试加载外挂字幕。(配置文件有新增条目 [dev] )
  35. * 播放列表:下一集保持相同版本。(限emby,配置文件有新增条目)
  36. * mpc 修复多版本播放回传失败。
  37. */
  38.  
  39. let config = {
  40. logLevel: 2,
  41. disableOpenFolder: false, // false 改为 true 则禁用打开文件夹的按钮。
  42. };
  43.  
  44. let fistTime = true;
  45.  
  46. let logger = {
  47. error: function (...args) {
  48. if (config.logLevel >= 1) {
  49. console.log('%cerror', 'color: yellow; font-style: italic; background-color: blue;', ...args);
  50. }
  51. },
  52. info: function (...args) {
  53. if (config.logLevel >= 2) {
  54. console.log('%cinfo', 'color: yellow; font-style: italic; background-color: blue;', ...args);
  55. }
  56. },
  57. debug: function (...args) {
  58. if (config.logLevel >= 3) {
  59. console.log('%cdebug', 'color: yellow; font-style: italic; background-color: blue;', ...args);
  60. }
  61. },
  62. }
  63.  
  64. async function sleep(ms) {
  65. return new Promise(resolve => setTimeout(resolve, ms));
  66. }
  67.  
  68. function removeErrorWindows() {
  69. let okButtonList = document.querySelectorAll('button[data-id="ok"]');
  70. let state = false;
  71. for (let index = 0; index < okButtonList.length; index++) {
  72. const element = okButtonList[index];
  73. if (element.textContent.search(/(了解|好的|知道|Got It)/) != -1) {
  74. element.click();
  75. state = true;
  76. }
  77. }
  78.  
  79. let jellyfinSpinner = document.querySelector('div.docspinner');
  80. if (jellyfinSpinner) {
  81. jellyfinSpinner.remove();
  82. state = true;
  83. };
  84.  
  85. return state;
  86. }
  87.  
  88. function switchLocalStorage(key, defaultValue = 'true', trueValue = 'true', falseValue = 'false') {
  89. if (key in localStorage) {
  90. let value = (localStorage.getItem(key) === trueValue) ? falseValue : trueValue;
  91. localStorage.setItem(key, value);
  92. } else {
  93. localStorage.setItem(key, defaultValue)
  94. }
  95. console.log('switchLocalStorage ', key, ' to ', localStorage.getItem(key))
  96. }
  97.  
  98. function setModeSwitchMenu(storageKey, menuStart = '', menuEnd = '', defaultValue = '关闭', trueValue = '开启', falseValue = '关闭') {
  99. let switchNameMap = { 'true': trueValue, 'false': falseValue, null: defaultValue };
  100. let menuId = GM_registerMenuCommand(menuStart + switchNameMap[localStorage.getItem(storageKey)] + menuEnd, clickMenu);
  101.  
  102. function clickMenu() {
  103. GM_unregisterMenuCommand(menuId);
  104. switchLocalStorage(storageKey)
  105. menuId = GM_registerMenuCommand(menuStart + switchNameMap[localStorage.getItem(storageKey)] + menuEnd, clickMenu);
  106. }
  107.  
  108. }
  109.  
  110. function sendDataToLocalServer(data, path) {
  111. let url = `http://127.0.0.1:58000/${path}/`;
  112. GM_xmlhttpRequest({
  113. method: 'POST',
  114. url: url,
  115. data: JSON.stringify(data),
  116. headers: {
  117. 'Content-Type': 'application/json'
  118. },
  119. });
  120. }
  121.  
  122. async function embyToLocalPlayer(playbackUrl, request, response) {
  123. let data = {
  124. ApiClient: ApiClient,
  125. fistTime: fistTime,
  126. playbackData: response,
  127. playbackUrl: playbackUrl,
  128. request: request,
  129. mountDiskEnable: localStorage.getItem('mountDiskEnable'),
  130.  
  131. };
  132. sendDataToLocalServer(data, 'embyToLocalPlayer');
  133. for (const times of Array(15).keys()) {
  134. await sleep(200);
  135. if (removeErrorWindows()) {
  136. logger.info(`remove error window used time: ${(times + 1) * 0.2}`);
  137. break;
  138. };
  139. }
  140. fistTime = false;
  141. }
  142.  
  143. function isHidden(el) {
  144. return (el.offsetParent === null);
  145. }
  146.  
  147. function getVisibleElement(elList) {
  148. if (!elList) return;
  149. if (NodeList.prototype.isPrototypeOf(elList)) {
  150. for (let i = 0; i < elList.length; i++) {
  151. if (!isHidden(elList[i])) {
  152. return elList[i];
  153. }
  154. }
  155. } else {
  156. return elList;
  157. }
  158. }
  159.  
  160. async function addOpenFolderElement() {
  161. if (config.disableOpenFolder) return;
  162. let mediaSources = null;
  163. for (const _ of Array(5).keys()) {
  164. await sleep(500);
  165. mediaSources = getVisibleElement(document.querySelectorAll('div.mediaSources'));
  166. if (mediaSources) break;
  167. }
  168. if (!mediaSources) return;
  169. let pathDiv = mediaSources.querySelector('div[class="sectionTitle sectionTitle-cards"] > div');
  170. if (!pathDiv || pathDiv.className == 'mediaInfoItems' || pathDiv.id == 'addFileNameElement') return;
  171. let full_path = pathDiv.textContent;
  172. if (!full_path.match(/[/:]/)) return;
  173. if (full_path.match(/\d{1,3}\.?\d{0,2} (MB|GB)/)) return;
  174.  
  175. let openButtonHtml = `<a id="openFolderButton" is="emby-linkbutton" class="raised item-tag-button
  176. nobackdropfilter emby-button" ><i class="md-icon button-icon button-icon-left">link</i>Open Folder</a>`
  177. pathDiv.insertAdjacentHTML('beforebegin', openButtonHtml);
  178. let btn = mediaSources.querySelector('a#openFolderButton');
  179. btn.addEventListener("click", () => {
  180. logger.info(full_path);
  181. sendDataToLocalServer({ full_path: full_path }, 'openFolder');
  182. });
  183. }
  184.  
  185. async function addFileNameElement(url, request) {
  186. let mediaSources = null;
  187. for (const _ of Array(5).keys()) {
  188. await sleep(500);
  189. mediaSources = getVisibleElement(document.querySelectorAll('div.mediaSources'));
  190. if (mediaSources) break;
  191. }
  192. if (!mediaSources) return;
  193. let pathDivs = mediaSources.querySelectorAll('div[class="sectionTitle sectionTitle-cards"] > div');
  194. if (!pathDivs) return;
  195. pathDivs = Array.from(pathDivs);
  196. let _pathDiv = pathDivs[0];
  197. if (!/\d{4}\/\d+\/\d+/.test(_pathDiv.textContent)) return;
  198. if (_pathDiv.id == 'addFileNameElement') return;
  199.  
  200. let response = await originFetch(url, request);
  201. let data = await response.json();
  202. data = data.MediaSources;
  203.  
  204. for (let index = 0; index < pathDivs.length; index++) {
  205. const pathDiv = pathDivs[index];
  206. let filePath = data[index].Path;
  207. let fileName = filePath.split('\\').pop().split('/').pop();
  208. let fileDiv = `<div id="addFileNameElement">${fileName}</div> `
  209. pathDiv.insertAdjacentHTML('beforebegin', fileDiv);
  210. }
  211. }
  212.  
  213. const originFetch = fetch;
  214. unsafeWindow.fetch = async (url, request) => {
  215. if (url.indexOf('/PlaybackInfo?UserId') != -1) {
  216. if (url.indexOf('IsPlayback=true') != -1 && localStorage.getItem('webPlayerEnable') != 'true') {
  217. let response = await originFetch(url, request);
  218. let data = await response.clone().json();
  219. if (data.MediaSources[0].Path.search(/\Wbackdrop/i) == -1) {
  220. embyToLocalPlayer(url, request, data);
  221. return
  222. }
  223. } else {
  224. addOpenFolderElement();
  225. addFileNameElement(url, request);
  226. }
  227. }
  228. return originFetch(url, request);
  229. }
  230.  
  231. function initXMLHttpRequest() {
  232. const open = XMLHttpRequest.prototype.open;
  233. XMLHttpRequest.prototype.open = function (...args) {
  234. // 正常请求不匹配的网址
  235. let url = args[1]
  236. if (url.indexOf('playQueues?type=video') == -1) {
  237. return open.apply(this, args);
  238. }
  239. // 请求前拦截
  240. if (url.indexOf('playQueues?type=video') != -1
  241. && localStorage.getItem('webPlayerEnable') != 'true') {
  242. fetch(url, {
  243. method: args[0],
  244. headers: {
  245. 'Accept': 'application/json',
  246. }
  247. })
  248. .then(response => response.json())
  249. .then((res) => {
  250. let data = {
  251. playbackData: res,
  252. playbackUrl: url,
  253. mountDiskEnable: localStorage.getItem('mountDiskEnable'),
  254.  
  255. };
  256. sendDataToLocalServer(data, 'plexToLocalPlayer');
  257. });
  258. return;
  259. }
  260. return open.apply(this, args);
  261. }
  262. }
  263.  
  264. // 初始化请求并拦截 plex
  265. initXMLHttpRequest()
  266.  
  267. setModeSwitchMenu('webPlayerEnable', '脚本在当前服务器 已', '', '启用', '禁用', '启用')
  268. setModeSwitchMenu('mountDiskEnable', '读取硬盘模式已经 ')