586 lines
20 KiB
JavaScript
586 lines
20 KiB
JavaScript
//@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<JadefinMod>} */
|
|
_loadingMods_dedupe = new Set();
|
|
/** @type {{module: JadefinMod, name: String, url: String}[]} */
|
|
_loadingMods_all = [];
|
|
/** @type {Promise<void>} */
|
|
_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<any>} */
|
|
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);
|
|
}
|
|
}));
|
|
}
|
|
|
|
})());
|