- // ==UserScript==
- // @name embyToLocalPlayer
- // @name:zh-CN embyToLocalPlayer
- // @name:en embyToLocalPlayer
- // @namespace https://github.com/kjtsune/embyToLocalPlayer
- // @version 2024.11.05
- // @description Emby/Jellyfin 调用外部本地播放器,并回传播放记录。适配 Plex。
- // @description:zh-CN Emby/Jellyfin 调用外部本地播放器,并回传播放记录。适配 Plex。
- // @description:en Play in an external player. Update watch history to Emby/Jellyfin server. Support Plex.
- // @author Kjtsune
- // @match *://*/web/index.html*
- // @match *://*/*/web/index.html*
- // @match *://*/web/
- // @match *://*/*/web/
- // @match https://app.plex.tv/*
- // @icon https://www.google.com/s2/favicons?sz=64&domain=emby.media
- // @grant unsafeWindow
- // @grant GM_info
- // @grant GM_xmlhttpRequest
- // @grant GM_registerMenuCommand
- // @grant GM_unregisterMenuCommand
- // @grant GM_getValue
- // @grant GM_setValue
- // @grant GM_deleteValue
- // @run-at document-start
- // @connect 127.0.0.1
- // @license MIT
- // ==/UserScript==
- 'use strict';
- /*global ApiClient*/
-
- (function () {
- 'use strict';
- let fistTime = true;
- let config = {
- logLevel: 2,
- disableOpenFolder: undefined, // undefined 改为 true 则禁用打开文件夹的按钮。
- crackFullPath: undefined,
- };
-
- const originFetch = fetch;
-
- let logger = {
- error: function (...args) {
- if (config.logLevel >= 1) {
- console.log('%cerror', 'color: yellow; font-style: italic; background-color: blue;', ...args);
- }
- },
- info: function (...args) {
- if (config.logLevel >= 2) {
- console.log('%cinfo', 'color: yellow; font-style: italic; background-color: blue;', ...args);
- }
- },
- debug: function (...args) {
- if (config.logLevel >= 3) {
- console.log('%cdebug', 'color: yellow; font-style: italic; background-color: blue;', ...args);
- }
- },
- }
-
- function myBool(value) {
- if (Array.isArray(value) && value.length === 0) return false;
- if (value !== null && typeof value === 'object' && Object.keys(value).length === 0) return false;
- return Boolean(value);
- }
-
- async function sleep(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
- }
-
- function isHidden(el) {
- return (el.offsetParent === null);
- }
-
- function getVisibleElement(elList) {
- if (!elList) return;
- if (Object.prototype.isPrototypeOf.call(NodeList.prototype, elList)) {
- for (let i = 0; i < elList.length; i++) {
- if (!isHidden(elList[i])) {
- return elList[i];
- }
- }
- } else {
- return elList;
- }
- }
-
- function _init_config_main() {
- function _init_config_by_key(confKey) {
- let confLocal = localStorage.getItem(confKey);
- if (confLocal == null) return;
- if (confLocal == 'true') {
- GM_setValue(confKey, true);
-
- } else if (confLocal == 'false') {
- GM_setValue(confKey, false);
- }
- let confGM = GM_getValue(confKey, null);
- if (confGM !== null) { config[confKey] = confGM };
- }
- _init_config_by_key('crackFullPath');
- }
-
- function switchLocalStorage(key, defaultValue = 'true', trueValue = 'true', falseValue = 'false') {
- if (key in localStorage) {
- let value = (localStorage.getItem(key) === trueValue) ? falseValue : trueValue;
- localStorage.setItem(key, value);
- } else {
- localStorage.setItem(key, defaultValue)
- }
- logger.info('switchLocalStorage ', key, ' to ', localStorage.getItem(key));
- }
-
- function setModeSwitchMenu(storageKey, menuStart = '', menuEnd = '', defaultValue = '关闭', trueValue = '开启', falseValue = '关闭') {
- let switchNameMap = { 'true': trueValue, 'false': falseValue, null: defaultValue };
- let menuId = GM_registerMenuCommand(menuStart + switchNameMap[localStorage.getItem(storageKey)] + menuEnd, clickMenu);
-
- function clickMenu() {
- GM_unregisterMenuCommand(menuId);
- switchLocalStorage(storageKey)
- menuId = GM_registerMenuCommand(menuStart + switchNameMap[localStorage.getItem(storageKey)] + menuEnd, clickMenu);
- }
-
- }
-
- function removeErrorWindows() {
- let okButtonList = document.querySelectorAll('button[data-id="ok"]');
- let state = false;
- for (let index = 0; index < okButtonList.length; index++) {
- const element = okButtonList[index];
- if (element.textContent.search(/(了解|好的|知道|Got It)/) != -1) {
- element.click();
- state = true;
- }
- }
-
- let jellyfinSpinner = document.querySelector('div.docspinner');
- if (jellyfinSpinner) {
- jellyfinSpinner.remove();
- state = true;
- };
-
- return state;
- }
-
- async function removeErrorWindowsMultiTimes() {
- for (const times of Array(15).keys()) {
- await sleep(200);
- if (removeErrorWindows()) {
- logger.info(`remove error window used time: ${(times + 1) * 0.2}`);
- break;
- };
- }
- }
-
- function sendDataToLocalServer(data, path) {
- let url = `http://127.0.0.1:58000/${path}/`;
- GM_xmlhttpRequest({
- method: 'POST',
- url: url,
- data: JSON.stringify(data),
- headers: {
- 'Content-Type': 'application/json'
- },
- });
- logger.info(path, data);
- }
-
- async function addOpenFolderElement() {
- if (config.disableOpenFolder) return;
- let mediaSources = null;
- for (const _ of Array(5).keys()) {
- await sleep(500);
- mediaSources = getVisibleElement(document.querySelectorAll('div.mediaSources'));
- if (mediaSources) break;
- }
- if (!mediaSources) return;
- let pathDiv = mediaSources.querySelector('div[class^="sectionTitle sectionTitle-cards"] > div');
- if (!pathDiv || pathDiv.className == 'mediaInfoItems' || pathDiv.id == 'addFileNameElement') return;
- let full_path = pathDiv.textContent;
- if (!full_path.match(/[/:]/)) return;
- if (full_path.match(/\d{1,3}\.?\d{0,2} (MB|GB)/)) return;
-
- let openButtonHtml = `<a id="openFolderButton" is="emby-linkbutton" class="raised item-tag-button
- nobackdropfilter emby-button" ><i class="md-icon button-icon button-icon-left">link</i>Open Folder</a>`
- pathDiv.insertAdjacentHTML('beforebegin', openButtonHtml);
- let btn = mediaSources.querySelector('a#openFolderButton');
- btn.addEventListener('click', () => {
- logger.info(full_path);
- sendDataToLocalServer({ full_path: full_path }, 'openFolder');
- });
- }
-
- async function addFileNameElement(url, request) {
- let mediaSources = null;
- for (const _ of Array(5).keys()) {
- await sleep(500);
- mediaSources = getVisibleElement(document.querySelectorAll('div.mediaSources'));
- if (mediaSources) break;
- }
- if (!mediaSources) return;
- let pathDivs = mediaSources.querySelectorAll('div[class^="sectionTitle sectionTitle-cards"] > div');
- if (!pathDivs) return;
- pathDivs = Array.from(pathDivs);
- let _pathDiv = pathDivs[0];
- if (!/\d{4}\/\d+\/\d+/.test(_pathDiv.textContent)) return;
- if (_pathDiv.id == 'addFileNameElement') return;
-
- let response = await originFetch(url, request);
- let data = await response.json();
- data = data.MediaSources;
-
- for (let index = 0; index < pathDivs.length; index++) {
- const pathDiv = pathDivs[index];
- let filePath = data[index].Path;
- let fileName = filePath.split('\\').pop().split('/').pop();
- fileName = (config.crackFullPath) ? filePath : fileName;
- let fileDiv = `<div id="addFileNameElement">${fileName}</div> `
- pathDiv.insertAdjacentHTML('beforebegin', fileDiv);
- }
- }
-
- let serverName = null;
- let episodesInfoCache = []; // ['type:[Episodes|NextUp|Items]', resp]
- let episodesInfoRe = /\/Episodes\?IsVirtual|\/NextUp\?Series|\/Items\?ParentId=\w+&Filters=IsNotFolder&Recursive=true/; // Items已排除播放列表
- // 点击位置:Episodes 继续观看,如果是即将观看,可能只有一集的信息 | NextUp 新播放或媒体库播放 | Items 季播放。 只有 Episodes 返回所有集的数据。
- let playlistInfoCache = null;
- let resumeRawInfoCache = null;
- let resumePlaybakCache = {};
- let resumeItemDataCache = {};
- let allPlaybackCache = {};
- let allItemDataCache = {};
-
- function makeItemIdCorrect(itemId) {
- if (serverName !== 'emby') { return itemId; }
- if (!resumeRawInfoCache || !episodesInfoCache) { return itemId; }
- let resumeIds = resumeRawInfoCache.map(item => item.Id);
- if (resumeIds.includes(itemId)) { return itemId; }
- let pageId = window.location.href.match(/\/item\?id=(\d+)/)?.[1];
- if (resumeIds.includes(pageId) && itemId == episodesInfoCache[0].Id) {
- // 解决从继续观看进入集详情页时,并非播放第一集,却请求首集视频文件信息导致无法播放。
- // 手动解决方法:从下方集卡片点击播放,或从集卡片再次进入集详情页后播放。
- // 本函数的副作用:集详情页底部的第一集卡片点播放按钮会播放当前集。
- // 副作用解决办法:再点击一次,或者点第一集卡片进入详情页后再播放。不过一般也不怎么会回头看第一集。
- return pageId;
-
- } else if (window.location.href.match(/serverId=/)) {
- return itemId; // 仅处理首页继续观看和集详情页,其他页面忽略。
- }
- let correctSeaId = episodesInfoCache.find(item => item.Id == itemId)?.SeasonId;
- let correctItemId = resumeRawInfoCache.find(item => item.SeasonId == correctSeaId)?.Id;
- if (correctSeaId && correctItemId) {
- logger.info(`makeItemIdCorrect, old=${itemId}, new=${correctItemId}`)
- return correctItemId;
- }
- return itemId;
- }
-
- async function embyToLocalPlayer(playbackUrl, request, playbackData, extraData) {
- let data = {
- ApiClient: ApiClient,
- playbackData: playbackData,
- playbackUrl: playbackUrl,
- request: request,
- mountDiskEnable: localStorage.getItem('mountDiskEnable'),
- extraData: extraData,
- fistTime: fistTime,
- };
- sendDataToLocalServer(data, 'embyToLocalPlayer');
- removeErrorWindowsMultiTimes();
- fistTime = false;
- }
-
- async function apiClientGetWithCache(itemId, cacheList, funName) {
- for (const cache of cacheList) {
- if (itemId in cache) {
- logger.info(`HIT ${funName} itemId=${itemId}`)
- return cache[itemId];
- }
- }
- logger.info(`MISS ${funName} itemId=${itemId}`)
- let resInfo;
- switch (funName) {
- case 'getPlaybackInfo':
- resInfo = await ApiClient.getPlaybackInfo(itemId);
- break;
- case 'getItem':
- resInfo = await ApiClient.getItem(ApiClient._serverInfo.UserId, itemId);
- break;
- default:
- break;
- }
- for (const cache of cacheList) {
- cache[itemId] = resInfo;
- }
- return resInfo;
- }
-
- async function getPlaybackWithCace(itemId) {
- return apiClientGetWithCache(itemId, [resumePlaybakCache, allPlaybackCache], 'getPlaybackInfo');
- }
-
- async function getItemInfoWithCace(itemId) {
- return apiClientGetWithCache(itemId, [resumeItemDataCache, allItemDataCache], 'getItem');
- }
-
- async function dealWithPlaybakInfo(raw_url, url, options) {
- console.time('dealWithPlaybakInfo');
- let rawId = url.match(/\/Items\/(\w+)\/PlaybackInfo/)[1];
- episodesInfoCache = episodesInfoCache[0] ? episodesInfoCache[1].clone() : null;
- let itemId = rawId;
- let [playbackData, mainEpInfo, episodesInfoData] = await Promise.all([
- getPlaybackWithCace(itemId), // originFetch(raw_url, request), 可能会 NoCompatibleStream
- getItemInfoWithCace(itemId),
- episodesInfoCache?.json(),
- ]);
- console.timeEnd('dealWithPlaybakInfo');
- episodesInfoData = (episodesInfoData && episodesInfoData.Items) ? episodesInfoData.Items : null;
- episodesInfoCache = episodesInfoData;
- let correctId = makeItemIdCorrect(itemId);
- url = url.replace(`/${rawId}/`, `/${correctId}/`)
- if (itemId != correctId) {
- itemId = correctId;
- [playbackData, mainEpInfo] = await Promise.all([
- getPlaybackWithCace(itemId),
- getItemInfoWithCace(itemId),
- ]);
- let startPos = mainEpInfo.UserData.PlaybackPositionTicks;
- url = url.replace('StartTimeTicks=0', `StartTimeTicks=${startPos}`);
- }
- let playlistData = (playlistInfoCache && playlistInfoCache.Items) ? playlistInfoCache.Items : null;
- episodesInfoCache = []
- let extraData = {
- mainEpInfo: mainEpInfo,
- episodesInfo: episodesInfoData,
- playlistInfo: playlistData,
- gmInfo: GM_info,
- userAgent: navigator.userAgent,
- }
- playlistInfoCache = null;
- // resumeInfoCache = null;
- logger.info(extraData);
- if (playbackData.MediaSources[0].Path.search(/\Wbackdrop/i) == -1) {
- let _req = options ? options : raw_url;
- embyToLocalPlayer(url, _req, playbackData, extraData);
- return true;
- }
- return false;
- }
-
- async function cacheResumeItemInfo() {
- for (let [globalCache, getFun] of [[resumePlaybakCache, getPlaybackWithCace], [resumeItemDataCache, getItemInfoWithCace]]) {
-
- if (!myBool(resumeRawInfoCache)) { return; }
- let resumeIds = resumeRawInfoCache.slice(0, 5).map(item => item.Id);
- let cacheDataAcc = {};
-
- if (myBool(globalCache)) {
- cacheDataAcc = globalCache;
- resumeIds = resumeIds.filter(id => !(id in globalCache));
- if (resumeIds.length == 0) { return; }
-
- }
- let itemInfoList = await Promise.all(
- resumeIds.map(id => getFun(id))
- )
- globalCache = itemInfoList.reduce((acc, result, index) => {
- acc[resumeIds[index]] = result;
- return acc;
- }, cacheDataAcc);
- }
-
- }
-
- async function cloneAndCacheFetch(resp, key, cache) {
- const data = await resp.clone().json();
- cache[key] = data;
- }
-
- let itemInfoRe = /Items\/(\w+)\?/;
-
- unsafeWindow.fetch = async (url, options) => {
- const raw_url = url;
- let urlType = typeof url;
- if (urlType != 'string') {
- url = raw_url.url;
- }
- if (serverName === null) {
- serverName = typeof ApiClient === 'undefined' ? null : ApiClient._appName.split(' ')[0].toLowerCase();
- } else {
- if (typeof ApiClient != 'undefined' && ApiClient._deviceName != 'embyToLocalPlayer' && localStorage.getItem('webPlayerEnable') != 'true') {
- ApiClient._deviceName = 'embyToLocalPlayer'
- }
- }
-
- // 适配播放列表及媒体库的全部播放、随机播放。限电影及音乐视频。
- if (url.includes('Items?') && (url.includes('Limit=300') || url.includes('Limit=1000')) || url.includes('SpecialFeatures')) {
- let _resp = await originFetch(raw_url, options);
- if (serverName == 'emby') {
- await ApiClient._userViewsPromise.then(result => {
- let viewsItems = result.Items;
- let viewsIds = [];
- viewsItems.forEach(item => {
- viewsIds.push(item.Id);
- });
- let viewsRegex = viewsIds.join('|');
- viewsRegex = `ParentId=(${viewsRegex})`
- if (!RegExp(viewsRegex).test(url)) { // 点击季播放美化标题所需,并非媒体库随机播放。
- episodesInfoCache = ['Items', _resp.clone()]
- logger.info('episodesInfoCache', episodesInfoCache);
- logger.info('viewsRegex', viewsRegex);
- return _resp;
- }
- }).catch(error => {
- console.error('Error occurred: ', error);
- });
- }
-
- playlistInfoCache = null;
- let _resd = await _resp.clone().json();
- if (url.includes('SpecialFeatures')) {
- _resd.Items = _resd
- }
- if (!_resd.Items[0]) {
- logger.error('playlist is empty, skip');
- return _resp;
- }
- if (['Movie', 'MusicVideo'].includes(_resd.Items[0].Type) || url.includes('SpecialFeatures')) {
- playlistInfoCache = _resd
- logger.info('playlistInfoCache', playlistInfoCache);
- }
- return _resp
- }
- // 获取各集标题等,仅用于美化标题,放后面避免误拦截首页右键媒体库随机播放数据。
- let _epMatch = url.match(episodesInfoRe);
- if (_epMatch) {
- _epMatch = _epMatch[0].split(['?'])[0].substring(1); // Episodes|NextUp|Items
- let _resp = await originFetch(raw_url, options);
- episodesInfoCache = [_epMatch, _resp.clone()]
- logger.info('episodesInfoCache', episodesInfoCache);
- return _resp
- }
- if (url.includes('Items/Resume') && url.includes('MediaTypes=Video')) {
- let _resp = await originFetch(raw_url, options);
- let _resd = await _resp.clone().json();
- resumeRawInfoCache = _resd.Items;
- cacheResumeItemInfo();
- logger.info('resumeRawInfoCache', resumeRawInfoCache);
- return _resp
- }
- // 缓存 itemInfo ,可能匹配到 Items/Resume,故放后面。
- if (url.match(itemInfoRe)) {
- let itemId = url.match(itemInfoRe)[1];
- let resp = await originFetch(raw_url, options);
- cloneAndCacheFetch(resp, itemId, allItemDataCache);
- return resp;
- }
- try {
- if (url.indexOf('/PlaybackInfo?UserId') != -1) {
- if (url.indexOf('IsPlayback=true') != -1 && localStorage.getItem('webPlayerEnable') != 'true') {
- if (await dealWithPlaybakInfo(raw_url, url, options)) { return; } // Emby
- } else {
- let itemId = url.match(/\/Items\/(\w+)\/PlaybackInfo/)[1];
- addOpenFolderElement();
- addFileNameElement(url, options);
- let resp = await originFetch(raw_url, options);
- cloneAndCacheFetch(resp, itemId, allPlaybackCache)
- return resp;
- }
- } else if (url.indexOf('/Playing/Stopped') != -1 && localStorage.getItem('webPlayerEnable') != 'true') {
- return
- }
- } catch (error) {
- logger.error(error, raw_url, url);
- removeErrorWindowsMultiTimes();
- return
- }
- return originFetch(raw_url, options);
- }
-
- function initXMLHttpRequest() {
-
- const originOpen = XMLHttpRequest.prototype.open;
- const originSend = XMLHttpRequest.prototype.send;
- const originSetHeader = XMLHttpRequest.prototype.setRequestHeader;
-
- XMLHttpRequest.prototype.setRequestHeader = function (header, value) {
- this._headers[header] = value;
- return originSetHeader.apply(this, arguments);
- }
-
- XMLHttpRequest.prototype.open = function (method, url) {
- this._method = method;
- this._url = url;
- this._headers = {};
-
- if (serverName === null && this._url.indexOf('X-Plex-Product') != -1) { serverName = 'plex' };
- let catchPlex = (serverName == 'plex' && this._url.indexOf('playQueues?type=video') != -1)
- if (catchPlex && localStorage.getItem('webPlayerEnable') != 'true') { // Plex
- fetch(this._url, {
- method: this._method,
- headers: {
- 'Accept': 'application/json',
- }
- })
- .then(response => response.json())
- .then((res) => {
- let extraData = {
- gmInfo: GM_info,
- userAgent: navigator.userAgent,
- };
- let data = {
- playbackData: res,
- playbackUrl: this._url,
- mountDiskEnable: localStorage.getItem('mountDiskEnable'),
- extraData: extraData,
- };
- sendDataToLocalServer(data, 'plexToLocalPlayer');
- });
- return;
- }
- return originOpen.apply(this, arguments);
- }
-
- XMLHttpRequest.prototype.send = function (body) {
-
- let catchJellyfin = (this._method === 'POST' && this._url.endsWith('PlaybackInfo'))
- if (catchJellyfin && localStorage.getItem('webPlayerEnable') != 'true') { // Jellyfin
- let pbUrl = this._url;
- body = JSON.parse(body);
- let _body = {};
- ['MediaSourceId', 'StartTimeTicks', 'UserId'].forEach(key => {
- _body[key] = body[key]
- });
- let query = new URLSearchParams(_body).toString();
- pbUrl = `${pbUrl}?${query}`
- let options = {
- headers: this._headers,
- };
- dealWithPlaybakInfo(pbUrl, pbUrl, options);
- return;
- }
- originSend.apply(this, arguments);
- }
- }
-
- initXMLHttpRequest();
-
- setModeSwitchMenu('webPlayerEnable', '脚本在当前服务器 已', '', '启用', '禁用', '启用');
- setModeSwitchMenu('mountDiskEnable', '读取硬盘模式已经 ');
-
- _init_config_main();
- })();