embyToLocalPlayer

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

当前为 2023-12-13 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         embyToLocalPlayer
// @name:zh-CN   embyToLocalPlayer
// @name:en      embyToLocalPlayer
// @namespace    https://github.com/kjtsune/embyToLocalPlayer
// @version      1.1.12.1
// @description  需要 Python。Emby/Jellyfin 调用外部本地播放器,并回传播放记录。适配 Plex。
// @description:zh-CN 需要 Python。Emby/Jellyfin 调用外部本地播放器,并回传播放记录。适配 Plex。
// @description:en  Require Python. Play in an external player. Update watch history to Emby/Jellyfin server. Support Plex.
// @author       Kjtsune
// @match        *://*/web/index.html*
// @match        *://*/*/web/index.html*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=emby.media
// @grant        unsafeWindow
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @run-at       document-start
// @connect      127.0.0.1
// @license MIT
// ==/UserScript==
'use strict';
/*
2023-12-11:
1. 美化 mpv pot 标题。
2. 改善版本筛选逻辑。
2023-12-07:
1. mpv 播放列表避免与官方 autoload.lua 冲突。
2. pot 修复读盘模式播放列表漏播第零季选集。(详见 FAQ 隐藏功能)
* 版本间累积更新:
  * 追更 TG 通知。(详见 FAQ 隐藏功能)
  * 适配 Emby beta 版本 api 变动。
  * bgm.tv: 适配上游搜索结果变动。
  * bgm.tv: 增加旧版搜索 api 备选。
  * trakt: 适配上游新剧缺失单集 imdb/tvdb。
  * .bat 强制使用 utf-8 编码。
  * 默认启用日志文件。
  * pot 播放列表 未加载完成时可退出。
  * 网络流:外挂 sup 支持(限 Emby v4.8.0.55 | mpv)。升级 Emby 记得备份,无法回退。
2023-10-04:
1. pot 和 vlc(Linux/macOS) 网络外挂字幕时,使用连播代替播放列表。`.ini` > playlist
2. 高版本 macOS 自启方案。@Eatsolx
* 版本间累积更新:
  * 增加:bangumi.tv bgm.tv 单向同步支持。见 FAQ。
  * Jellyfin: 适配 基础 URL。
  * 可一键更新。见 FAQ。
  * 可保存日志。ini > dev > log_file。
  * Trakt:未启用播放列表时同步失效。
  * Trakt:自启时误弹认证窗口。
  * 播放列表:文件命名不规范时失效。
  * 减少回传次数。**油猴脚本也需要更新**
  * 播放网络流时:pot 播放列表:降低添加条目速度,减少异常。
  * 播放网络流时:mpc 切换进度时 api 无响应,导致提前回传。
*/
(function () {
    'use strict';

    let fistTime = true;
    let config = {
        logLevel: 2,
        disableOpenFolder: false, // false 改为 true 则禁用打开文件夹的按钮。
    };

    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);
            }
        },
    }

    async function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    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;
    }

    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 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'
            },
        });
    }

    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;
            };
        }
    }

    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;
    }

    function isHidden(el) {
        return (el.offsetParent === null);
    }

    function getVisibleElement(elList) {
        if (!elList) return;
        if (NodeList.prototype.isPrototypeOf(elList)) {
            for (let i = 0; i < elList.length; i++) {
                if (!isHidden(elList[i])) {
                    return elList[i];
                }
            }
        } else {
            return elList;
        }
    }

    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();
            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;

    const originFetch = fetch;
    unsafeWindow.fetch = async (url, request) => {
        if (serverName === null) {
            serverName = typeof ApiClient === 'undefined' ? null : ApiClient._appName.split(' ')[0].toLowerCase();
        }
        // 获取各集标题等
        let _epMatch = url.match(episodesInfoRe);
        if (_epMatch) {
            _epMatch = _epMatch[0].split(['?'])[0].substring(1); // Episodes|NextUp|Items
            let _resp = await originFetch(url, request);
            episodesInfoCache = [_epMatch, _resp.clone()]
            logger
            logger.info(episodesInfoCache)
            return _resp
        }
        // 适配播放列表及媒体库的全部播放、随机播放。限电影及音乐视频。
        if (url.indexOf('Items?') != -1 && url.indexOf('Limit=300') != -1) {
            playlistInfoCache = null;
            let _resp = await originFetch(url, request);
            let _resd = await _resp.clone().json();
            if (['Movie', 'MusicVideo'].includes(_resd.Items[0].Type)) {
                playlistInfoCache = _resd
            }
            return _resp

        }
        try {
            if (url.indexOf('/PlaybackInfo?UserId') != -1) {
                if (url.indexOf('IsPlayback=true') != -1 && localStorage.getItem('webPlayerEnable') != 'true') {
                    let match = url.match(/\/Items\/(\w+)\/PlaybackInfo/);
                    let itemId = match ? match[1] : null;
                    let userId = ApiClient._serverInfo.UserId;
                    let [playbackResp, mainEpInfo] = await Promise.all([
                        originFetch(url, request),
                        ApiClient.getItem(userId, itemId),
                    ]);
                    let playbackData = await playbackResp.clone().json();
                    let episodesInfoData = episodesInfoCache[0] ? await episodesInfoCache[1].clone().json() : null;
                    episodesInfoData = episodesInfoData ? episodesInfoData.Items : null;
                    let playlistData = playlistInfoCache ? playlistInfoCache.Items : null;
                    episodesInfoCache = []
                    let extraData = {
                        mainEpInfo: mainEpInfo,
                        episodesInfo: episodesInfoData,
                        playlistInfo: playlistData,
                    }
                    playlistInfoCache = null;
                    logger.info(extraData);
                    if (playbackData.MediaSources[0].Path.search(/\Wbackdrop/i) == -1) {
                        embyToLocalPlayer(url, request, playbackData, extraData);
                        return
                    }
                } else {
                    addOpenFolderElement();
                    addFileNameElement(url, request);
                }
            } else if (url.indexOf('/Playing/Stopped') != -1 && localStorage.getItem('webPlayerEnable') != 'true') {
                return
            }
        } catch (error) {
            logger.error(error);
            removeErrorWindowsMultiTimes();
            return
        }
        return originFetch(url, request);
    }

    function initXMLHttpRequest() {
        const open = XMLHttpRequest.prototype.open;
        XMLHttpRequest.prototype.open = function (...args) {
            let url = args[1]
            if (serverName === null && url.indexOf('X-Plex-Product') != -1) { serverName = 'plex' };
            // 正常请求不匹配的网址
            if (url.indexOf('playQueues?type=video') == -1) {
                return open.apply(this, args);
            }
            // 请求前拦截
            if (url.indexOf('playQueues?type=video') != -1
                && localStorage.getItem('webPlayerEnable') != 'true') {
                fetch(url, {
                    method: args[0],
                    headers: {
                        'Accept': 'application/json',
                    }
                })
                    .then(response => response.json())
                    .then((res) => {
                        let data = {
                            playbackData: res,
                            playbackUrl: url,
                            mountDiskEnable: localStorage.getItem('mountDiskEnable'),

                        };
                        sendDataToLocalServer(data, 'plexToLocalPlayer');
                    });
                return;
            }
            return open.apply(this, args);
        }
    }

    // 初始化请求并拦截 plex
    initXMLHttpRequest()

    setModeSwitchMenu('webPlayerEnable', '脚本在当前服务器 已', '', '启用', '禁用', '启用')
    setModeSwitchMenu('mountDiskEnable', '读取硬盘模式已经 ')
})();