//@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("/")); /** @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; 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() { // 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}; 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 } })); } }); } 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"]); window["webpackChunk"].push([["custom_init"], {}, a => { /** @type {((id: number | string) => any & {[key: string]: any})} */ this.webpackLoad = a; }]); if (!this.webpackLoad) { this.log.e("Couldn't obtain webpackLoad"); return; } /** @type {(id: number | string) => any} */ this.webpackTryLoad = id => { try { return this.webpackLoad?.(id); } catch (e) { this.log.w(`Failed to load webpack module ${id}`); this.log.dir(e); return null; } }; /** @type {(id: any) => string} */ this.webpackIdToChunkJS = Object.values(this.webpackLoad).find(v => typeof(v) == "function" && v.toString().indexOf(`+".chunk.js"`) != -1); /** @type {(id: any) => string} */ this.webpackIdToChunkCSS = Object.values(this.webpackLoad).find(v => typeof(v) == "function" && v.toString().indexOf(`+".css"`) != -1); const webpackIdToChunkJSKey = Object.keys(this.webpackLoad).find(k => this.webpackLoad?.[k] == this.webpackIdToChunkJS); if (!this.webpackIdToChunkJS || !webpackIdToChunkJSKey) { this.log.e("Couldn't obtain webpackIdToChunkJS"); return; } this.webpackLoad[webpackIdToChunkJSKey] = function(id) { const rv = self.webpackIdToChunkJS?.(id); self.log.v(`Webpack converted chunk ID to JS name: ${id} -> ${rv}`); return rv; }; /** @type {(name: any, cb: any, sid: any, id: any) => any} */ this.webpackLoadChunkLowLevel = Object.values(this.webpackLoad).find(v => typeof(v) == "function" && v.toString().indexOf(`document.head.appendChild`) != -1); const webpackLoadChunkLowLevelKey = Object.keys(this.webpackLoad).find(k => this.webpackLoad?.[k] == this.webpackLoadChunkLowLevel); if (!this.webpackLoadChunkLowLevel || !webpackLoadChunkLowLevelKey) { this.log.e("Couldn't obtain webpackLoadChunkLowLevel"); return; } this.webpackLoad[webpackLoadChunkLowLevelKey] = function(name, cb, sid, id) { const _cb = cb; self.log.v(`Webpack loading chunk ${id} (${sid}) from ${name}`); /* cb = (event) => { self.log.v(`Webpack loaded chunk ${id} (${sid}) from ${name}: ${event.type}`); return _cb(event); }; */ const rv = self.webpackLoadChunkLowLevel?.(name, cb, sid, id); return rv; }; /** @type {(id: any) => Promise} */ this.webpackLoadChunk = Object.values(this.webpackLoad).find(v => typeof(v) == "function" && v.toString().indexOf(`Object.keys(`) != -1 && v.toString().indexOf(`}),[])`) != -1); // HACKFIX: Load all chunks when Jadefin initializes! // webpackIdToChunkJS contains all IDs either ahead of === or : // FIXME: This smells like race condition hell! We might be too late for this kind of hooking. // FIXME: Ideally, don't. This makes startup take ages. const webpackChunks = [...this.webpackIdToChunkJS.toString().matchAll(/\d+(?=:|=)/g)].map(v => parseInt(v[0])); await Promise.all(webpackChunks.map(id => this.webpackLoadChunk?.(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.initMods(); 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.webpackLoad ? Object.values(this.webpackLoad).find(v => typeof(v) == "object" && Object.keys(v).length > 10) : null; } /** * @param {(e: any) => any} cb */ findWebpackRawLoad(cb) { return Object.keys(this.webpackModuleFuncs).map(id => this.webpackTryLoad?.(id)).filter(e => e && cb(e)); } /** * @param {(e: any) => any} cb */ findWebpackModules(cb) { return Object.keys(this.webpackModuleFuncs).map(id => this.webpackTryLoad?.(id)?.default).filter(e => e && cb(e)); } /** * @param {(e: any) => any} cb */ findWebpackFunctions(cb) { return Object.keys(this.webpackModuleFuncs).map(id => this.webpackTryLoad?.(id)).filter(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.webpackLoad?.(id); } catch (e) { this.log.w(`Failed to load webpack module ${id}`); this.log.w(this.webpackModuleFuncs[id].toString()); this.log.dir(e); unsafe.push(id); } } return unsafe; } /** * @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 initMods(list) { 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); } })); } })());