Initial jellyfin-multilang rewrite
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
(() => {
|
||||
const BASE_PATH = window.location.pathname.split("/web/")[0] || "";
|
||||
const MENU_ID = "mlUserRulesMenuItem";
|
||||
const USER_RULES_ROUTE = "multilang/user-rules";
|
||||
let lastMenuItemId = "";
|
||||
let userRulesRenderRun = 0;
|
||||
|
||||
function getClientToken() {
|
||||
try {
|
||||
return window.ApiClient?.accessToken?.() || "";
|
||||
} catch (_) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function hashParts() {
|
||||
const raw = (location.hash || "").replace(/^#\/?/, "");
|
||||
const queryIndex = raw.indexOf("?");
|
||||
const path = (queryIndex >= 0 ? raw.slice(0, queryIndex) : raw).toLowerCase();
|
||||
const query = queryIndex >= 0 ? raw.slice(queryIndex + 1) : "";
|
||||
return { path, params: new URLSearchParams(query) };
|
||||
}
|
||||
|
||||
function getCurrentUserId() {
|
||||
const fromHash = hashParts().params.get("userId");
|
||||
if (fromHash) return fromHash;
|
||||
try {
|
||||
return window.ApiClient?.getCurrentUserId?.() || "";
|
||||
} catch (_) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function userRulesHref() {
|
||||
const params = new URLSearchParams();
|
||||
const userId = getCurrentUserId();
|
||||
if (userId) params.set("userId", userId);
|
||||
params.set("multilang", "1");
|
||||
return `${BASE_PATH}/web/#/${USER_RULES_ROUTE}?${params.toString()}`;
|
||||
}
|
||||
|
||||
function isUserRulesRoute() {
|
||||
const parts = hashParts();
|
||||
return parts.path === USER_RULES_ROUTE && parts.params.get("multilang") === "1";
|
||||
}
|
||||
|
||||
function pathSegments(pathname) {
|
||||
return String(pathname || "")
|
||||
.toLowerCase()
|
||||
.split("/")
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function looksLikeItemId(value) {
|
||||
return /^[0-9a-f]{32}$/.test(value) || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value);
|
||||
}
|
||||
|
||||
function isUserItemsEndpoint(segments) {
|
||||
const usersIndex = segments.lastIndexOf("users");
|
||||
if (usersIndex < 0 || segments[usersIndex + 2] !== "items") return false;
|
||||
|
||||
const tail = segments.slice(usersIndex + 3);
|
||||
if (tail.length === 0) return true;
|
||||
if (tail.length !== 1) return false;
|
||||
return looksLikeItemId(tail[0]) || tail[0] === "latest" || tail[0] === "resume" || tail[0] === "nextup";
|
||||
}
|
||||
|
||||
function isRootItemsEndpoint(segments) {
|
||||
const itemsIndex = segments.lastIndexOf("items");
|
||||
if (itemsIndex < 0) return false;
|
||||
|
||||
const tail = segments.slice(itemsIndex + 1);
|
||||
if (tail.length === 0) return true;
|
||||
if (tail.length !== 1) return false;
|
||||
return looksLikeItemId(tail[0]) || tail[0] === "latest" || tail[0] === "resume" || tail[0] === "nextup";
|
||||
}
|
||||
|
||||
function isShowsListEndpoint(segments) {
|
||||
const showsIndex = segments.lastIndexOf("shows");
|
||||
if (showsIndex < 0) return false;
|
||||
|
||||
const tail = segments.slice(showsIndex + 1);
|
||||
if (tail.length === 1) return tail[0] === "nextup";
|
||||
if (tail.length === 2 && looksLikeItemId(tail[0])) return tail[1] === "episodes" || tail[1] === "seasons";
|
||||
return false;
|
||||
}
|
||||
|
||||
function isGenresEndpoint(segments) {
|
||||
const genresIndex = segments.lastIndexOf("genres");
|
||||
if (genresIndex < 0) return false;
|
||||
|
||||
const tail = segments.slice(genresIndex + 1);
|
||||
return tail.length === 0 || (tail.length === 1 && looksLikeItemId(tail[0]));
|
||||
}
|
||||
|
||||
function shouldProxyEndpoint(pathname) {
|
||||
const segments = pathSegments(pathname);
|
||||
return isUserItemsEndpoint(segments) ||
|
||||
isRootItemsEndpoint(segments) ||
|
||||
isShowsListEndpoint(segments) ||
|
||||
isGenresEndpoint(segments);
|
||||
}
|
||||
|
||||
function getItemsProxyUrl(urlInput) {
|
||||
const url = urlInput instanceof Request ? urlInput.url : String(urlInput || "");
|
||||
if (!url) return null;
|
||||
const u = new URL(url, window.location.origin);
|
||||
if (u.origin !== window.location.origin) return null;
|
||||
const lower = u.pathname.toLowerCase();
|
||||
if (lower.includes("/multilang/")) return null;
|
||||
if (lower.includes("/images/") || lower.endsWith("/images")) return null;
|
||||
if (!shouldProxyEndpoint(u.pathname)) return null;
|
||||
|
||||
const locale = document.documentElement?.lang?.trim() || navigator.language || "";
|
||||
const token = getClientToken();
|
||||
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : "";
|
||||
const localeParam = locale ? `&mlLocale=${encodeURIComponent(locale)}` : "";
|
||||
return `${BASE_PATH}/Multilang/ItemsProxy?url=${encodeURIComponent(`${u.pathname}${u.search}`)}${localeParam}${tokenParam}`;
|
||||
}
|
||||
|
||||
function ensureUserRulesLink() {
|
||||
let menuItem = document.getElementById(MENU_ID);
|
||||
if (!(location.hash || "").toLowerCase().includes("mypreferences")) {
|
||||
menuItem?.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const href = userRulesHref();
|
||||
if (menuItem) {
|
||||
menuItem.href = href;
|
||||
wireUserRulesLink(menuItem);
|
||||
return;
|
||||
}
|
||||
|
||||
const links = [...document.querySelectorAll('a[href^="#/mypreferences"]')];
|
||||
const insertAfter = links[links.length - 1];
|
||||
if (!insertAfter) return;
|
||||
|
||||
const cloned = insertAfter.cloneNode(true);
|
||||
cloned.id = MENU_ID;
|
||||
cloned.dataset.mlUserRulesLink = "1";
|
||||
cloned.href = href;
|
||||
cloned.removeAttribute("title");
|
||||
cloned.removeAttribute("aria-label");
|
||||
[...cloned.classList]
|
||||
.filter((c) => c.startsWith("lnk"))
|
||||
.forEach((c) => cloned.classList.remove(c));
|
||||
const textEl = cloned.querySelector(".listItemBodyText");
|
||||
if (textEl) textEl.textContent = "Metadata language rules";
|
||||
cloned.querySelectorAll(".material-icons").forEach((el) => el.remove());
|
||||
const iconEl = document.createElement("span");
|
||||
iconEl.className = "material-icons listItemIcon listItemIcon-transparent translate";
|
||||
iconEl.setAttribute("aria-hidden", "true");
|
||||
(cloned.querySelector(".listItemBody") || cloned).before(iconEl);
|
||||
wireUserRulesLink(cloned);
|
||||
insertAfter.parentElement.insertBefore(cloned, insertAfter.nextSibling);
|
||||
}
|
||||
|
||||
function wireUserRulesLink(link) {
|
||||
if (link.dataset.mlUserRulesClick === "1") return;
|
||||
link.dataset.mlUserRulesClick = "1";
|
||||
link.addEventListener("click", (event) => {
|
||||
openUserRulesPage(event);
|
||||
}, true);
|
||||
}
|
||||
|
||||
function visiblePage() {
|
||||
const pages = [...document.querySelectorAll(".mainAnimatedPage, .page")];
|
||||
return pages.reverse().find((page) => {
|
||||
if (page.closest("#ml-user") || page.classList.contains("hide")) return false;
|
||||
const style = window.getComputedStyle(page);
|
||||
return style.display !== "none" && style.visibility !== "hidden";
|
||||
});
|
||||
}
|
||||
|
||||
function userRulesHost() {
|
||||
return ensureUserRulesShell().querySelector("#ml-user-host");
|
||||
}
|
||||
|
||||
function ensureUserRulesShell() {
|
||||
let shell = document.getElementById("ml-user-shell");
|
||||
if (shell) {
|
||||
positionUserRulesShell(shell);
|
||||
shell.hidden = false;
|
||||
return shell;
|
||||
}
|
||||
|
||||
shell = document.createElement("div");
|
||||
shell.id = "ml-user-shell";
|
||||
shell.className = "mainAnimatedPage type-interior page";
|
||||
shell.style.position = "fixed";
|
||||
shell.style.left = "0";
|
||||
shell.style.right = "0";
|
||||
shell.style.bottom = "0";
|
||||
shell.style.zIndex = "100";
|
||||
shell.style.overflow = "auto";
|
||||
shell.style.background = "var(--theme-body-bg, var(--background-color, #101010))";
|
||||
shell.style.color = "inherit";
|
||||
|
||||
const host = document.createElement("div");
|
||||
host.id = "ml-user-host";
|
||||
host.className = "content-primary";
|
||||
shell.appendChild(host);
|
||||
document.body.appendChild(shell);
|
||||
positionUserRulesShell(shell);
|
||||
return shell;
|
||||
}
|
||||
|
||||
function positionUserRulesShell(shell) {
|
||||
const header = document.querySelector(".skinHeader, .headerTop, header");
|
||||
const top = header ? Math.max(0, Math.round(header.getBoundingClientRect().bottom)) : 0;
|
||||
shell.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
function closeUserRulesShellIfInactive() {
|
||||
if (isUserRulesRoute()) return;
|
||||
document.getElementById("ml-user-shell")?.remove();
|
||||
}
|
||||
|
||||
function openUserRulesPage(event) {
|
||||
event?.preventDefault?.();
|
||||
event?.stopPropagation?.();
|
||||
event?.stopImmediatePropagation?.();
|
||||
history.pushState({}, "", userRulesHref());
|
||||
scheduleUserRulesPageRender();
|
||||
}
|
||||
|
||||
function handleUserRulesClick(event) {
|
||||
const link = event.target?.closest?.(`#${MENU_ID}, [data-ml-user-rules-link='1'], a[href*="multilang=1"]`);
|
||||
if (!link) return;
|
||||
openUserRulesPage(event);
|
||||
}
|
||||
|
||||
function executeScript(script) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const next = document.createElement("script");
|
||||
[...script.attributes].forEach((attr) => next.setAttribute(attr.name, attr.value));
|
||||
if (script.src) {
|
||||
next.onload = () => resolve();
|
||||
next.onerror = () => reject(new Error(`Failed to load ${script.src}`));
|
||||
next.src = script.src;
|
||||
} else {
|
||||
next.text = script.textContent || "";
|
||||
}
|
||||
document.body.appendChild(next);
|
||||
if (!script.src) resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function renderUserRulesPage(run) {
|
||||
if (!isUserRulesRoute() || run !== userRulesRenderRun) return;
|
||||
|
||||
const host = userRulesHost();
|
||||
if (!host) return;
|
||||
if (host.dataset.mlUserRulesRendered === "1" && document.getElementById("ml-user")) return;
|
||||
|
||||
host.dataset.mlUserRulesRendered = "1";
|
||||
host.innerHTML = "<div class=\"fieldDescription\">Loading Multilang settings...</div>";
|
||||
|
||||
const response = await fetch(`${BASE_PATH}/web/configurationpage?name=MultilangUser`, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const fragment = await response.text();
|
||||
if (run !== userRulesRenderRun || !isUserRulesRoute()) return;
|
||||
|
||||
const parsed = new DOMParser().parseFromString(fragment, "text/html");
|
||||
const sourcePage = parsed.getElementById("ml-user");
|
||||
const sourceContent = sourcePage?.querySelector?.(".content-primary");
|
||||
if (!sourceContent) throw new Error("Multilang user page fragment missing");
|
||||
|
||||
const scripts = [...sourceContent.querySelectorAll("script")];
|
||||
scripts.forEach((script) => script.remove());
|
||||
|
||||
const page = document.createElement("div");
|
||||
page.id = "ml-user";
|
||||
page.className = "ml-root";
|
||||
while (sourceContent.firstChild) page.appendChild(sourceContent.firstChild);
|
||||
host.replaceChildren(page);
|
||||
|
||||
for (const script of scripts) {
|
||||
if (run !== userRulesRenderRun || !isUserRulesRoute()) return;
|
||||
await executeScript(script);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleUserRulesPageRender() {
|
||||
userRulesRenderRun++;
|
||||
if (!isUserRulesRoute()) {
|
||||
closeUserRulesShellIfInactive();
|
||||
return;
|
||||
}
|
||||
|
||||
const run = userRulesRenderRun;
|
||||
[0, 100, 350].forEach((delay) => {
|
||||
setTimeout(() => {
|
||||
renderUserRulesPage(run).catch((err) => console.error("[Multilang] failed to render user rules page", err));
|
||||
}, delay);
|
||||
});
|
||||
}
|
||||
|
||||
function captureLastItemId(target) {
|
||||
const el = target?.closest?.("[data-id]");
|
||||
const id = (el?.getAttribute("data-id") || "").match(/[0-9a-f]{32}/i)?.[0];
|
||||
if (id) lastMenuItemId = id;
|
||||
}
|
||||
|
||||
function collectDialogItemIds(form) {
|
||||
const scan = (value) => String(value || "").match(/[0-9a-f]{32}/ig) || [];
|
||||
return [...new Set([...scan(form?.querySelector?.(".fldSelectedItemIds")?.value), ...scan(lastMenuItemId), ...scan(location.hash)])];
|
||||
}
|
||||
|
||||
function refreshItems(itemIds) {
|
||||
const token = getClientToken();
|
||||
const headers = token ? { "X-Emby-Token": token, "X-MediaBrowser-Token": token } : {};
|
||||
itemIds.forEach((itemId) => {
|
||||
fetch(`${BASE_PATH}/Multilang/RefreshItem/${itemId}?includeChildren=true${token ? `&token=${encodeURIComponent(token)}` : ""}`, { method: "POST", headers });
|
||||
});
|
||||
}
|
||||
|
||||
function openDebugItem(itemIds) {
|
||||
const itemId = itemIds[0];
|
||||
if (!itemId) return;
|
||||
const token = getClientToken();
|
||||
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : "";
|
||||
window.open(`${BASE_PATH}/Multilang/DebugUi/${encodeURIComponent(itemId)}${tokenParam}`, "_blank", "noopener");
|
||||
}
|
||||
|
||||
function ensureRefreshDialogOption(root) {
|
||||
const container = root?.closest?.(".formDialog") || root?.querySelector?.(".formDialog");
|
||||
const title = container?.querySelector?.(".formDialogHeaderTitle");
|
||||
const select = container?.querySelector?.("#selectMetadataRefreshMode");
|
||||
if (!container || !title || !/refresh metadata/i.test(title.textContent || "") || !select || select.querySelector("option[value='multilang']")) return;
|
||||
|
||||
const option = document.createElement("option");
|
||||
option.value = "multilang";
|
||||
option.textContent = "Refresh Multilang metadata";
|
||||
select.appendChild(option);
|
||||
|
||||
if (!container.querySelector("[data-ml-debug-item]")) {
|
||||
const debugButton = document.createElement("button");
|
||||
debugButton.type = "button";
|
||||
debugButton.className = "raised button-submit emby-button";
|
||||
debugButton.dataset.mlDebugItem = "1";
|
||||
debugButton.textContent = "Debug Multilang";
|
||||
debugButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openDebugItem(collectDialogItemIds(container));
|
||||
}, true);
|
||||
(select.closest(".inputContainer") || select.parentElement || container).appendChild(debugButton);
|
||||
}
|
||||
|
||||
const form = container.querySelector("form");
|
||||
if (!form || form.dataset.mlHooked === "1") return;
|
||||
form.dataset.mlHooked = "1";
|
||||
form.addEventListener("submit", (event) => {
|
||||
if (select.value !== "multilang") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
refreshItems(collectDialogItemIds(form));
|
||||
(container.closest(".dialogContainer") || container).remove();
|
||||
document.querySelectorAll(".dialogBackdrop,.dialog-overlay,.dialog-backdrop").forEach((el) => el.remove());
|
||||
}, true);
|
||||
}
|
||||
|
||||
function mlUrlFromImageUrl(rawUrl) {
|
||||
const urlText = String(rawUrl || "").replace(/&/gi, "&");
|
||||
if (!/tag=ml(?::|%3a|%253a)/i.test(urlText)) return "";
|
||||
const u = new URL(urlText, window.location.origin);
|
||||
const tag = u.searchParams.get("tag") || "";
|
||||
if (!tag.startsWith("ml:")) return "";
|
||||
let url = decodeURIComponent(tag.slice(3)).trim();
|
||||
if (!url) return "";
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
if (!url.startsWith("/")) url = "/" + url;
|
||||
if (BASE_PATH && !url.toLowerCase().startsWith(BASE_PATH.toLowerCase() + "/")) url = BASE_PATH + url;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function rewriteElementImages(el) {
|
||||
if (!el || el.nodeType !== 1) return;
|
||||
["src", "data-src"].forEach((attr) => {
|
||||
const current = el.getAttribute(attr);
|
||||
const next = mlUrlFromImageUrl(current);
|
||||
if (next) el.setAttribute(attr, next);
|
||||
});
|
||||
const style = el.getAttribute("style");
|
||||
if (style) {
|
||||
const next = style.replace(/url\((['"]?)([^'")]+)\1\)/g, (match, quote, url) => {
|
||||
const ml = mlUrlFromImageUrl(url);
|
||||
return ml ? `url("${ml}")` : match;
|
||||
});
|
||||
if (next !== style) el.setAttribute("style", next);
|
||||
}
|
||||
}
|
||||
|
||||
function scanNode(node) {
|
||||
if (!node || node.nodeType !== 1) return;
|
||||
rewriteElementImages(node);
|
||||
node.querySelectorAll("[style],[src],[data-src]").forEach(rewriteElementImages);
|
||||
ensureRefreshDialogOption(node);
|
||||
}
|
||||
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = function (input, init) {
|
||||
const method = (init && init.method) || (input instanceof Request ? input.method : "GET");
|
||||
if (String(method).toUpperCase() !== "GET") return originalFetch(input, init);
|
||||
const proxyUrl = getItemsProxyUrl(input);
|
||||
if (!proxyUrl) return originalFetch(input, init);
|
||||
if (input instanceof Request) return originalFetch(new Request(proxyUrl, input));
|
||||
return originalFetch(proxyUrl, init);
|
||||
};
|
||||
|
||||
const originalXhrOpen = XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open = function (method, url) {
|
||||
const proxyUrl = typeof method === "string" && method.toUpperCase() === "GET" ? getItemsProxyUrl(url) : null;
|
||||
return proxyUrl ? originalXhrOpen.call(this, method, proxyUrl, arguments[2], arguments[3], arguments[4]) : originalXhrOpen.apply(this, arguments);
|
||||
};
|
||||
|
||||
const observer = new MutationObserver((mutations) => mutations.forEach((m) => {
|
||||
if (m.type === "childList") m.addedNodes.forEach(scanNode);
|
||||
if (m.type === "attributes") rewriteElementImages(m.target);
|
||||
}));
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["style", "src", "data-src"] });
|
||||
|
||||
document.addEventListener("pointerdown", (event) => captureLastItemId(event.target));
|
||||
document.addEventListener("click", handleUserRulesClick, true);
|
||||
document.addEventListener("viewshow", () => requestAnimationFrame(() => {
|
||||
ensureUserRulesLink();
|
||||
scheduleUserRulesPageRender();
|
||||
}));
|
||||
window.addEventListener("hashchange", () => requestAnimationFrame(() => {
|
||||
ensureUserRulesLink();
|
||||
scheduleUserRulesPageRender();
|
||||
}));
|
||||
requestAnimationFrame(() => {
|
||||
scanNode(document.documentElement);
|
||||
ensureUserRulesLink();
|
||||
scheduleUserRulesPageRender();
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user