Files
jellyfin-multilang/src/Jellyfin.Plugin.Multilang/wwwroot/inject.js
T

444 lines
16 KiB
JavaScript

(() => {
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(/&amp;/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();
});
})();