//@ts-check import JadefinIntegrity from "./JadefinIntegrity.js"; import JadefinMod from "./JadefinMod.js"; import JadefinModules from "./JadefinModules.js"; import JadefinUtils from "./JadefinUtils.js"; export default JadefinIntegrity("Jadefin", import.meta.url, () => window["Jadefin"] = new (class Jadefin extends JadefinMod { modUrl = import.meta.url; root = this.modUrl.substring(0, this.modUrl.lastIndexOf("/")); version = this.root.substring(this.root.lastIndexOf("/") + 1); /** @type {{[id: string]: JadefinMod}} */ mods = {}; /** @type {Set} */ _loadingMods_dedupe = new Set(); /** @type {{module: JadefinMod, name: String, url: String}[]} */ _loadingMods_all = []; /** @type {Promise} */ _loadingMods; /** @type {any} */ _webpackModuleFuncs; /** @type {{[id: number]: {0: number[], 1: {[id: number]: function}}}} */ _webpackIdToMeta = {}; /** @type {{[id: number]: string[]}} */ _webpackIdToJSs = {}; /** @type {{[id: number]: string}} */ _webpackIdToJS = {}; /** @type {{[id: number]: string}} */ _webpackIdToName = {}; _webpackBlacklistClient = JSON.parse(localStorage.getItem("jadefin-blacklist") || "[]"); /** @type {typeof this._webpackBlacklistClient} */ webpackBlacklistClient = new Proxy(this._webpackBlacklistClient, { deleteProperty: (target, property) => { delete target[property]; localStorage.setItem("jadefin-blacklist", JSON.stringify(this._webpackBlacklistClient)); return true; }, set: (target, property, value, receiver) => { target[property] = value; localStorage.setItem("jadefin-blacklist", JSON.stringify(this._webpackBlacklistClient)); return true; } }); // To update, Jadefin.webpackBlacklistClient.push(0); and update this after a few reloads: JSON.stringify(Jadefin.webpackBlacklistClient) // Once done: Jadefin.webpackBlacklistClient.splice(0, Jadefin.webpackBlacklistClient.length) webpackBlacklistDefault = JSON.parse(`[0,"activity","node_modules.@mui.x-date-pickers","15088","node_modules.@mui.utils","node_modules.@mui.material","node_modules.react-transition-group","node_modules.date-fns.esm","23416","livetvtuner","syncPlay-core-players-GenericPlayer","47472","node_modules.@mui.system","55802"]`); /** @type {{[id: string]: any}} */ _webpackCache = {}; constructor() { super(); this.log.i(`Loading from ${this.modUrl}`); this.log.v("Test Verbose"); this.log.w("Test Warning"); this.log.e("Test Error"); this.initEarly(); JadefinModules.Jadefin = this; this._loadingMods = this._loadMods(); window["_JadefinLoaded"](); } initEarly() { if (this.webpackBlacklistClient.length) { this.webpackBlacklist = this.webpackBlacklistClient; } else { this.webpackBlacklist = this.webpackBlacklistDefault; } // Required for mods to be able to perform certain worker shenanigans. window.Worker = (class Worker extends window.Worker { constructor(scriptURL, options) { const args = {scriptURL, options}; window["Jadefin"].log.i(`Creating new worker: ${scriptURL}`); JadefinUtils.events.dispatchEvent(new CustomEvent(JadefinUtils.eventTypes.WORKER_CREATING, { detail: { args } })); super(args.scriptURL, args.options); JadefinUtils.events.dispatchEvent(new CustomEvent(JadefinUtils.eventTypes.WORKER_CREATED, { detail: { args, worker: this } })); } }); // Required with newer versions of Jellyfin and Jadefin: Fetch and patch runtime.bundle.js ourselves. // Thankfully this can happen async, Jellyfin will load whenever this is done. const runtimeBundleScript = Array.from(document.getElementsByTagName("script")).find(s => s.src.indexOf("runtime.jadefin.bundle.js") != -1); if (!runtimeBundleScript) { this._runtimeBundlePatched = false; this.log.e("Using vanilla runtime.bundle.js - some mods might not work properly!"); } else { this._runtimeBundlePatched = true; const url = runtimeBundleScript.src.replace("runtime.jadefin.bundle.js", "runtime.bundle.js"); this.log.i(`Fetching runtime.bundle.js from ${url}`); fetch(url).then(r => r.text()).then(async src => { this.log.i("Patching runtime.bundle.js"); this._runtimeBundleOrig = src; try { // __webpack_require__ in Compilation.js as reference // var d=f[e]={id:e,loaded:!1,exports:{}}; in minified version const matchRequire = src.match(/var [^=]+=([^\[]+)\[[^\[]+\]=\{[^}]*(?:id:[^;]+|loaded:[^;]+|exports:{}[^;]+){3};/); if (!matchRequire || matchRequire.index === undefined) { throw new Error("Couldn't find __webpack_require__"); } src = src.substring(0, matchRequire.index) + `window["_JadefinWebpackCache"]=${matchRequire[1]};` + src.substring(matchRequire.index); this.log.i("Running patched runtime.bundle.js"); eval(src); } catch (e) { this._runtimeBundlePatched = false; this.log.e("Patched runtime.bundle.js errored, running vanilla runtime.bundle.js as fallback"); this.log.dir(e); eval(this._runtimeBundleOrig); } }); } } async init(base) { const self = this; await super.init("Jadefin", this.modUrl); // Wait until webpackChunk and Emby exist. // It might be delayed due to the main jellyfin bundle being replaced by an inject script on Android. await JadefinUtils.waitUntil(() => window["webpackChunk"] && window["Emby"] && (!this._runtimeBundlePatched || window["_JadefinWebpackCache"])); this._webpackRawCache = window["_JadefinWebpackCache"]; if (!this._webpackRawCache) { this.log.w("Couldn't obtain _webpackRawCache"); } window["webpackChunk"].push([["custom_init"], {}, a => { /** @type {((id: number | string) => any & {[key: string]: any})} */ this.webpackRawLoad = a; }]); if (!this.webpackRawLoad) { this.log.e("Couldn't obtain webpackRawLoad"); return; } /** @type {(id: number | string) => any} */ this.webpackLoad = id => { const cached = this._webpackCache[id]; if (cached) { return cached; } const name = this.webpackIdToName(id); if (this.webpackBlacklist !== this.webpackBlacklistClient) { const cachedRaw = this._webpackRawCache?.[id]; if (cachedRaw && cachedRaw.loaded) { return this._webpackCache[id] = cachedRaw.exports; } // Some modules are known to break things when loaded too early. if (name.startsWith("session-login") || name.startsWith("syncPlay-ui-") || name.startsWith("user-display") || name.startsWith("activity") || name.startsWith("node_modules.@juggle") || name.startsWith("node_modules.@mui") || name.startsWith("hometab") || name.startsWith("users") || name.startsWith("livetvstatus") || name.startsWith("favourites") || false ) { return null; } } // Some modules shall only be loaded via their intended codepaths. if (this.webpackBlacklist.indexOf(name) != -1) { return null; } try { return this._webpackCache[id] = this.webpackRawLoad?.(id); } catch (e) { this.log.e(`Failed to load webpack module ${id} (${name})`); this.log.dir(e); this.webpackBlacklist.push(name); return null; } }; /** @type {(id: any) => string} */ this.webpackChunkIdToJS = Object.values(this.webpackRawLoad).find(v => typeof(v) == "function" && v.toString().indexOf(`+".chunk.js"`) != -1); /** @type {(id: any) => string} */ this.webpackIdToCSS = Object.values(this.webpackRawLoad).find(v => typeof(v) == "function" && v.toString().indexOf(`+".css"`) != -1); if (!this.webpackChunkIdToJS || !this.webpackIdToCSS) { this.log.e("Couldn't obtain webpackChunkIdToJS or webpackIdToCSS"); return; } /** @type {(name: any, cb: any, sid: any, id: any) => any} */ this.webpackPreloadChunkLowLevel = Object.values(this.webpackRawLoad).find(v => typeof(v) == "function" && v.toString().indexOf(`document.head.appendChild`) != -1); const webpackPreloadChunkLowLevelKey = Object.keys(this.webpackRawLoad).find(k => this.webpackRawLoad?.[k] == this.webpackPreloadChunkLowLevel); if (!this.webpackPreloadChunkLowLevel || !webpackPreloadChunkLowLevelKey) { this.log.e("Couldn't obtain webpackPreloadChunkLowLevel"); return; } this.webpackRawLoad[webpackPreloadChunkLowLevelKey] = function(name, cb, sid, id) { const _cb = cb; // self.log.v(`Webpack preloading chunk ${id} (${sid}) from ${name}`); cb = (event) => { self.log.v(`Webpack preloaded chunk ${id} (${sid}) from ${name}: ${event.type}`); return _cb(event); }; const rv = self.webpackPreloadChunkLowLevel?.(name, cb, sid, id); return rv; }; /** @type {(id: any) => Promise} */ this.webpackPreloadChunk = Object.values(this.webpackRawLoad).find(v => typeof(v) == "function" && v.toString().indexOf(`Object.keys(`) != -1 && v.toString().indexOf(`}),[])`) != -1); // webpackChunkIdToJS contains all IDs either ahead of === or : this.webpackChunkIds = [...this.webpackChunkIdToJS.toString().matchAll(/\d+(?=:|=)/g)].map(v => parseInt(v[0])); /** @type {any} */ this.webpackChunkJSs = {}; for (const id of this.webpackChunkIds) { this.webpackChunkJSs[this.webpackChunkIdToJS(id)] = id; } // Wait until everything else is ready. await JadefinUtils.waitUntil(() => this.webpackModuleFuncs); // this._webpackUnsafeModuleIDs = this.findUnsafeWebpackModules(); const initing = [ this.initHookHtmlVideoPlayer(), ]; await this._loadingMods; await this._initLoadingMods(); await Promise.all(initing); this.log.i("Ready"); } async initHookHtmlVideoPlayer() { const self = this; await JadefinUtils.waitUntil(() => JadefinModules.htmlVideoPlayer); const _createMediaElement = JadefinModules.htmlVideoPlayer.prototype.createMediaElement; JadefinModules.htmlVideoPlayer.prototype.createMediaElement = function(options) { if (!JadefinUtils.htmlVideoPlayers.delete(this)) { self.log.i("Adding new htmlVideoPlayer"); self.log.dir(this); JadefinUtils.htmlVideoPlayers.add(this); JadefinUtils.htmlVideoPlayer = this; JadefinUtils.events.dispatchEvent(new CustomEvent(JadefinUtils.eventTypes.HTML_VIDEO_PLAYER_CHANGED, { detail: { alive: true, player: this } })); } return _createMediaElement.call(this, options); } const _destroy = JadefinModules.htmlVideoPlayer.prototype.destroy; JadefinModules.htmlVideoPlayer.prototype.destroy = function() { if (JadefinUtils.htmlVideoPlayers.delete(this)) { self.log.i("Removing old htmlVideoPlayer"); self.log.dir(this); JadefinUtils.events.dispatchEvent(new CustomEvent(JadefinUtils.eventTypes.HTML_VIDEO_PLAYER_CHANGED, { detail: { alive: false, player: this } })); } return _destroy.call(this); } } get webpackModuleFuncs() { return this._webpackModuleFuncs ??= this.webpackRawLoad ? Object.values(this.webpackRawLoad).find(v => typeof(v) == "object" && Object.keys(v).length > 10) : null; } /** * @param {(e: any) => any} cb */ findWebpackRawLoad(cb) { return JadefinUtils.filterMap(Object.keys(this.webpackModuleFuncs).map(id => this.webpackLoad?.(id)), e => e && cb(e)); } /** * @param {(e: any) => any} cb */ findWebpackModules(cb) { return JadefinUtils.filterMap(Object.keys(this.webpackModuleFuncs).map(id => this.webpackLoad?.(id)?.default), e => e && cb(e)); } /** * @param {(e: any) => any} cb */ findWebpackFunctions(cb) { return JadefinUtils.filterMap(Object.keys(this.webpackModuleFuncs).map(id => this.webpackLoad?.(id)), e => e && e instanceof Function && cb(e)); } /** * @param {any} [modules] */ findUnsafeWebpackModules(modules) { const unsafe = []; for (const id of Object.keys(modules || this.webpackModuleFuncs)) { try { this.webpackRawLoad?.(id); } catch (e) { this.log.w(`Failed to load webpack module ${id}`); this.log.dir(this.webpackModuleFuncs[id]); this.log.dir(e); unsafe.push(id); } } return unsafe; } /** * @param {any} id * @return {typeof this._webpackIdToMeta[0]} */ webpackIdToMeta(id) { const meta = this._webpackIdToMeta[id]; if (meta) { return meta; } this.log.i("Rebuilding webpackIdToMeta"); this._webpackIdToMeta = {}; for (const meta of window["webpackChunk"]) { for (const pid of meta[0]) { this._webpackIdToMeta[pid] = meta; } for (const cid of Object.keys(meta[1])) { this._webpackIdToMeta[cid] = meta; } } return this._webpackIdToMeta[id]; } /** * @param {any} id * @return {string[]} */ webpackIdToJSs(id) { if (!this.webpackChunkIdToJS) { throw new Error("Calling webpackIdToJSs too early"); } const cached = this._webpackIdToJSs[id]; if (cached) { return cached; } const jss = []; const meta = this.webpackIdToMeta(id); if (!meta) { jss.push(this.webpackChunkIdToJS(parseInt(id))); return jss; } for (const pid of meta[0]) { jss.push(this.webpackChunkIdToJS(pid)); this._webpackIdToJSs[pid] = jss; } for (const cid of Object.keys(meta[1])) { jss.push(this.webpackChunkIdToJS(cid)); this._webpackIdToJSs[cid] = jss; } return jss; }; /** * @param {any} id * @return {string} */ webpackIdToJS(id) { const cached = this._webpackIdToJS[id]; if (cached) { return cached; } const jss = this.webpackIdToJSs(id); return this._webpackIdToJS[id] = jss.find(c => !/^\d/.test(c)) || jss.find(c => c.startsWith(`${id}.`)) || `${id}.unknown.chunk.js`; }; /** * @param {any} id * @return {string} */ webpackIdToName(id) { const cached = this._webpackIdToName[id]; if (cached) { return cached; } const name = this.webpackIdToJS(id); if (name.endsWith(".bundle.js")) { return this._webpackIdToName[id] = name.substring(0, name.length - ".bundle.js".length); } if (name.endsWith(".chunk.js")) { const split = name.lastIndexOf(".", name.length - ".chunk.js".length - 1); return this._webpackIdToName[id] = name.substring(0, split); } return this._webpackIdToName[id] = name; }; /** * @param {string} name */ getMod(name) { let found = this.mods[name]; if (found) { return found; } for (let mod of Object.values(this.mods)) { if (mod.isMod(name)) { return mod; } } return undefined; } /** * @param {string[]} [list] */ async _loadMods(list) { if (!list) { // HACK: I'm hosting Jadefin on my own Jellyfin subdomain, but want my own mods... oops! let listUrl = window.location.host == "jellyfin.0x0a.de" ? `${this.root}/mods_jade.json` : `${this.root}/mods.json`; listUrl = localStorage.getItem("jadefin-mods") || listUrl; this.log.i(`Loading main mod list ${listUrl}`); list = window["JadefinModList"] || (await fetch(listUrl).then(r => r.json())); } if (!list) { return; } await Promise.all(list.map(async (/** @type {string} */ entry) => { let isList = false; if (entry.startsWith("list:")) { isList = true; entry = entry.substring("list:".length); } let name = entry; let url = `${this.root}/mods/${entry}`; if (entry.startsWith("//")) { url = window.location.protocol + entry; } else if (entry.startsWith("/")) { url = window.location.origin + entry; } else if (entry.indexOf(":") < entry.indexOf("?")) { url = entry; } let split = name.indexOf("?"); if (split != -1) { name = name.substring(0, split); } split = name.lastIndexOf("/"); if (split != -1) { name = name.substring(split + 1); } split = name.lastIndexOf("."); if (split != -1) { name = name.substring(0, split); } if (isList) { this.log.i(`Loading mod list ${url}`); const list = await fetch(url).then(r => r.json()); if (list) { await this._loadMods(list); } return; } this.log.i(`Loading mod ${name} from ${url}`); try { /** @type {{default: JadefinMod}} */ const mod = await import(`${url}`); const module = mod.default; if (this._loadingMods_dedupe.delete(module)) { this._loadingMods_dedupe.add(module); throw new Error("Duplicate module"); } this._loadingMods_dedupe.add(module); this.mods[name] = module; this._loadingMods_all.push({module, name, url}); } catch (e) { this.log.e(`Failed to load ${name}`); this.log.dir(e); } })); } async _initLoadingMods() { await Promise.all(this._loadingMods_all.map(async (mod) => { this.log.i(`Initializing mod ${mod.name}`); try { await mod.module.init(mod.name, mod.url); } catch (e) { this.log.e(`Failed to initialize ${mod.name}`); this.log.dir(e); } })); } })());