// ==UserScript==
// @name embyToLocalPlayer
// @name:zh-CN embyToLocalPlayer
// @name:en embyToLocalPlayer
// @namespace https://github.com/kjtsune/embyToLocalPlayer
// @version 2025.05.09
// @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.emby.media/*
// @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
// @downloadURL https://update.greasyfork.icu/scripts/448648/embyToLocalPlayer.user.js
// @updateURL https://update.greasyfork.icu/scripts/448648/embyToLocalPlayer.meta.js
// ==/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();
if (isHidden(element)) { continue; }
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'
},
onerror: function (error) {
alert(`${url}\n请求错误,本地服务未运行,请查看使用说明。\nhttps://github.com/kjtsune/embyToLocalPlayer`);
console.error('请求错误:', error);
}
});
logger.info(path, data);
}
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 = {};
let metadataChangeRe = /\/MetadataEditor|\/Refresh\?/;
let metadataMayChange = false;
function cleanOptionalCache() {
resumeRawInfoCache = null;
resumePlaybakCache = {};
resumeItemDataCache = {};
allPlaybackCache = {};
allItemDataCache = {};
episodesInfoCache = []
}
function throttle(fn, delay) {
let lastTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastTime >= delay) {
lastTime = now;
fn.apply(this, args);
}
};
}
let addOpenFolderElement = throttle(_addOpenFolderElement, 100);
async function _addOpenFolderElement(itemId) {
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 itemData = (itemId in allItemDataCache) ? allItemDataCache[itemId] : null
let strmFile = (full_path.startsWith('http')) ? itemData?.Path : null
let openButtonHtml = `linkOpen Folder`
pathDiv.insertAdjacentHTML('beforebegin', openButtonHtml);
let btn = mediaSources.querySelector('a#openFolderButton');
if (strmFile) {
pathDiv.innerHTML = pathDiv.innerHTML + '
' + strmFile;
full_path = strmFile; // emby 会把 strm 内的链接当路径展示
}
btn.addEventListener('click', () => {
logger.info(full_path);
sendDataToLocalServer({ full_path: full_path }, 'openFolder');
});
}
async function addFileNameElement(resp) {
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 (_pathDiv.id == 'addFileNameElement') return;
let isAdmin = !/\d{4}\/\d+\/\d+/.test(_pathDiv.textContent); // 非管理员只有包含添加日期的文件类型 div
let isStrm = _pathDiv.textContent.startsWith('http');
if (isAdmin) {
if (!isStrm) { return; }
pathDivs = pathDivs.filter((_, index) => index % 2 === 0); // 管理员一个文件同时有路径和文件类型两个 div
}
let sources = await resp.clone().json();
sources = sources.MediaSources;
for (let index = 0; index < pathDivs.length; index++) {
const pathDiv = pathDivs[index];
let fileName = sources[index].Name; // 多版本的话,是版本名。
let filePath = sources[index].Path;
let strmFile = filePath.startsWith('http');
if (!strmFile) {
fileName = filePath.split('\\').pop().split('/').pop();
fileName = (config.crackFullPath && !isAdmin) ? filePath : fileName;
}
let fileDiv = `