(function () { var pluginId = "68540b76-ee74-436d-85ff-2abc884bbea6"; var copyLabel = "Copy Stream URL"; var actionLabel = "ShareLink"; var clientVersion = "1.0.6-ui-1"; var allowedItemStorageKey = "sharelinks.allowedItemId"; var guestClassName = "sharelinks-guest"; var hiddenAttr = "data-sharelinks-hidden"; var injectedAttr = "data-sharelinks-injected"; var configPromise = null; var userPromise = null; var userPromiseUserId = null; var guestStatePromise = null; var guestStatePromiseUserId = null; var booted = false; var scanQueued = false; var bootRetry = null; var observer = null; var menuContext = null; var shareableItemTypes = [ "movie", "series", "season", "episode", "video", "photo", "boxset", ]; var menuTriggerSelector = '[data-action="menu"], .btnMoreCommands, .btnCardOptions, .cardOverlayButton'; var itemTypeCache = {}; var itemTypeInFlight = {}; var shareTagPattern = /^sharelinks-[0-9a-f]{32}$/i; var tagHiddenAttr = "data-sharelinks-tag-hidden"; var historyPatched = false; var itemMenuActionIds = [ "resume", "playallfromhere", "queue", "queuenext", "shuffle", "instantmix", "multiselect", "addtocollection", "addtoplaylist", "editplaylist", "removefromcollection", "removefromplaylist", "movetotop", "movetobottom", "download", "downloadall", "copy-stream", "delete", "edit", "editimages", "editsubtitles", "editlyrics", "identify", "moremediainfo", "refresh", "record", "canceltimer", "cancelseriestimer", "share", "album", "artist", "lyrics", // Older Jellyfin web builds used these ids for the same commands. "moreinfo", "mediainfo", "editmetadata", "playlist", "copystream", ]; var durationOptions = [ { label: "1 hour", hours: 1 }, { label: "2 hours", hours: 2 }, { label: "4 hours", hours: 4 }, { label: "6 hours", hours: 6 }, { label: "12 hours", hours: 12 }, { label: "1 day", hours: 24 }, { label: "2 days", hours: 48 }, { label: "7 days", hours: 168 }, ]; function isFrench() { var lang = document.documentElement.getAttribute("lang") || (navigator && (navigator.language || navigator.userLanguage)) || "en"; return /^fr/i.test(lang); } var STRINGS = { en: { modalTitle: "Create guest link", modalBody: "Choose how long this link should stay valid.", dateLabel: "Or pick an exact expiry date and time:", create: "Create", cancel: "Cancel", copy: "Copy", done: "Done", pickDateFirst: "Pick a date and time first.", dateInvalid: "That date is not valid.", pickFuture: "Pick a time in the future.", multiUseLabel: "Let several people use this link", multiUseHint: "The link keeps working for anyone you send it to until it expires, instead of dying once the first person opens it.", multiUseLimit: "Up to {count} of them can watch at the same time.", resultMultiUseNote: "Anyone you send this link to can open it until it expires.", cannotDetermineItem: "Could not determine which item to share. Open the item page and retry.", adminOnly: "ShareLinks is available to administrators only.", disabled: "ShareLinks is disabled.", noShareUrl: "The server did not return a share URL.", couldNotCreate: "Could not create a guest link.", copiedNote: "The link was copied to your clipboard.", notCopiedNote: "The link was created, but the browser blocked automatic clipboard access.", resultCopiedTitle: "Share link copied", resultCreatedTitle: "Share link created", toastCopied: "Share link copied.", toastManual: "Select and copy the link manually.", hour: "hour", hours: "hours", day: "day", days: "days", }, fr: { modalTitle: "Créer un lien invité", modalBody: "Choisissez la durée de validité de ce lien.", dateLabel: "Ou choisissez une date et une heure d'expiration précises :", create: "Créer", cancel: "Annuler", copy: "Copier", done: "Terminé", pickDateFirst: "Choisissez d'abord une date et une heure.", dateInvalid: "Cette date n'est pas valide.", pickFuture: "Choisissez une date dans le futur.", multiUseLabel: "Autoriser plusieurs personnes à utiliser ce lien", multiUseHint: "Le lien reste valable pour toutes les personnes à qui vous l'envoyez jusqu'à son expiration, au lieu de mourir dès la première ouverture.", multiUseLimit: "Jusqu'a {count} d'entre elles peuvent regarder en meme temps.", resultMultiUseNote: "Toutes les personnes à qui vous envoyez ce lien peuvent l'ouvrir jusqu'à son expiration.", cannotDetermineItem: "Impossible de déterminer l'élément à partager. Ouvrez la page du média et réessayez.", adminOnly: "ShareLinks est réservé aux administrateurs.", disabled: "ShareLinks est désactivé.", noShareUrl: "Le serveur n'a pas renvoyé de lien de partage.", couldNotCreate: "Impossible de créer le lien invité.", copiedNote: "Le lien a été copié dans le presse-papiers.", notCopiedNote: "Le lien a été créé, mais le navigateur a bloqué l'accès automatique au presse-papiers.", resultCopiedTitle: "Lien de partage copié", resultCreatedTitle: "Lien de partage créé", toastCopied: "Lien de partage copié.", toastManual: "Sélectionnez et copiez le lien manuellement.", hour: "heure", hours: "heures", day: "jour", days: "jours", }, }; function t(key) { var lang = isFrench() ? "fr" : "en"; return (STRINGS[lang] && STRINGS[lang][key]) || STRINGS.en[key] || key; } function durationLabel(hours) { if (hours < 24) { return hours + " " + t(hours === 1 ? "hour" : "hours"); } var d = hours / 24; return d + " " + t(d === 1 ? "day" : "days"); } window.ShareLinksClientVersion = clientVersion; function ready() { return !!window.ApiClient && !!window.document && !!document.body; } function start() { if (booted) { return; } if (!ready()) { if (!bootRetry) { bootRetry = window.setTimeout(function () { bootRetry = null; start(); }, 250); } return; } booted = true; installHooks(); scheduleWork(); } function installHooks() { if (!historyPatched) { historyPatched = true; patchHistory(); } window.addEventListener("hashchange", scheduleWork, true); window.addEventListener("popstate", scheduleWork, true); document.addEventListener( "pointerdown", function (event) { rememberMenuContext(event.target); }, true ); observer = new MutationObserver(scheduleWork); observer.observe(document.body, { childList: true, subtree: true }); window.setInterval(scheduleWork, 3000); } function patchHistory() { var pushState = history.pushState; var replaceState = history.replaceState; history.pushState = function () { var result = pushState.apply(this, arguments); scheduleWork(); return result; }; history.replaceState = function () { var result = replaceState.apply(this, arguments); scheduleWork(); return result; }; } function scheduleWork() { if (scanQueued || !ready()) { return; } scanQueued = true; window.requestAnimationFrame(function () { scanQueued = false; refresh().catch(function () { // Best effort only. The menu hook should never block the web UI. }); }); } async function refresh() { rememberAllowedItemFromRoute(); await applyGuestLockdown(); await hideShareTagsFromNonAdmins(); await scanForMoreMenuActions(); } function apiGet(path) { return ApiClient.ajax({ type: "GET", url: ApiClient.getUrl(path), dataType: "json", }); } function apiPost(path, body) { return ApiClient.ajax({ type: "POST", url: ApiClient.getUrl(path), dataType: "json", contentType: "application/json", data: JSON.stringify(body || {}), }); } function getConfig() { if (!configPromise) { configPromise = ApiClient.getPluginConfiguration(pluginId).catch( function () { return {}; } ); } return configPromise; } /** * The web client is a single page app: signing out or switching accounts does * not reload the document, so anything cached for "the current user" has to be * keyed to the session it came from. Caching it for the lifetime of the page * let an admin's verdict survive into the next user's session. */ function getCurrentUser() { var userId = currentApiUserId(); if (!userPromise || userPromiseUserId !== userId) { userPromiseUserId = userId; userPromise = apiGet("Users/Me").catch(function () { return null; }); } return userPromise; } function currentApiUserId() { try { return ( (window.ApiClient && ApiClient.getCurrentUserId && ApiClient.getCurrentUserId()) || "" ); } catch (error) { return ""; } } function getGuestState() { var userId = currentApiUserId(); if (!guestStatePromise || guestStatePromiseUserId !== userId) { guestStatePromiseUserId = userId; guestStatePromise = apiGet("ShareLinks/GuestState").catch(function () { return null; }); } return guestStatePromise; } function hideBlockedGuestMenuItems() { var nodes = document.querySelectorAll(".actionSheetMenuItem"); Array.prototype.forEach.call(nodes, function (node) { if (!node || node.getAttribute("data-sharelinks-blocked") === "1") { return; } var dataId = String(node.getAttribute("data-id") || "").toLowerCase(); var label = getVisibleLabel(node).toLowerCase(); var icon = node.querySelector(".material-icons"); var iconClass = icon ? String(icon.className || "") : ""; var blocked = dataId === "playlist" || dataId === "addtoplaylist" || dataId === "addtocollection" || label.indexOf("liste de lecture") >= 0 || label.indexOf("playlist") >= 0 || label.indexOf("add to collection") >= 0 || label.indexOf("ajouter à la collection") >= 0 || (iconClass.indexOf("playlist_add") >= 0 && dataId !== "queue" && dataId !== "queuenext"); if (blocked) { node.setAttribute("data-sharelinks-blocked", "1"); node.style.setProperty("display", "none", "important"); } }); } async function applyGuestLockdown() { var context = await getGuestContext(); if (!context.locked) { return; } ensureGuestStyle(); ensurePluginHideStyle(context.hiddenSelectors); hideGuestControls(); hideBlockedGuestMenuItems(); // UX-only lockdown: the guest user's real access boundary is still the // server-side policy and item tags. This just keeps the web client out // of the user's way, while still letting the guest browse into the // shared tree (series -> season -> episode) when the server says the // item is visible to them. if (!context.allowedItemId) { return; } var verdict = await checkAllowedLocation(context.allowedItemId); if (verdict === false) { navigateToItem(context.allowedItemId); } // verdict === true: allowed, do nothing. // verdict === null: check still in flight or route is not item-scoped // in a way we can verify yet; do nothing to avoid flicker. } async function getGuestContext() { var config = await getConfig(); var user = await getCurrentUser(); var state = await getGuestState(); var prefix = config && config.GuestUsernamePrefix ? String(config.GuestUsernamePrefix) : "share-"; var username = user && user.Name ? String(user.Name) : ""; var lockdownEnabled = config && config.GuestModeLockdownEnabled !== false; if (state && state.lockdownEnabled === false) { lockdownEnabled = false; } var locked = lockdownEnabled && ((username && username.indexOf(prefix) === 0) || !!( state && (state.IsGuest === true || state.isGuest === true || state.GuestUserId || state.guestUserId) )); return { locked: locked, allowedItemId: extractAllowedItemId(state) || sessionStorage.getItem(allowedItemStorageKey) || null, username: username, prefix: prefix, lockdownEnabled: lockdownEnabled, hiddenSelectors: (state && (state.HiddenSelectors || state.hiddenSelectors)) || "", }; } function extractAllowedItemId(state) { if (!state) { return null; } return ( state.AllowedItemId || state.allowedItemId || state.ItemId || state.itemId || state.ShareItemId || state.shareItemId || null ); } function ensureGuestStyle() { if (document.body.classList.contains(guestClassName)) { return; } document.body.classList.add(guestClassName); if (document.getElementById("ShareLinksGuestStyle")) { return; } var style = document.createElement("style"); style.id = "ShareLinksGuestStyle"; style.textContent = "body." + guestClassName + " [" + hiddenAttr + '="1"],' + " body." + guestClassName + " .headerHomeButton," + " body." + guestClassName + " .mainDrawerButton," + " body." + guestClassName + " .headerSearchButton," + " body." + guestClassName + ' [data-action="addtoplaylist"],' + " body." + guestClassName + ' [data-action="addtocollection"],' + " body." + guestClassName + ' [data-id="playlist"],' + // The Modern layout (the default from Jellyfin 12) renders its own header // instead of .skinHeader, so its controls need their own selectors here. " body." + guestClassName + ' .MuiAppBar-root a[href^="#/"],' + " body." + guestClassName + ' .MuiAppBar-root [aria-controls="app-user-menu"],' + " body." + guestClassName + " .MuiAppBar-root .MuiToolbar-root > button:first-child," + " body." + guestClassName + ' [data-id="addtocollection"] { display: none !important; }' + // Cards stay clickable so guests can navigate season/episode cards on a shared series; // checkAllowedLocation() verifies the destination server-side and redirects if disallowed. " body." + guestClassName + " #castCollapsible a," + " body." + guestClassName + " .detailsGroupItem a," + " body." + guestClassName + " .genresGroup a," + " body." + guestClassName + " .studiosGroup a," + " body." + guestClassName + " .itemTags a," + " body." + guestClassName + " .mediaInfoItem a { pointer-events: none !important; cursor: default !important; }"; document.head.appendChild(style); } function ensurePluginHideStyle(selectors) { var value = String(selectors || "") .split(",") .map(function (part) { return part.trim(); }) .filter(Boolean); var existing = document.getElementById("ShareLinksPluginHideStyle"); if (value.length === 0) { if (existing) { existing.remove(); } return; } var css = value.join(", ") + " { display: none !important; }"; if (existing) { if (existing.textContent !== css) { existing.textContent = css; } return; } var style = document.createElement("style"); style.id = "ShareLinksPluginHideStyle"; style.textContent = css; document.head.appendChild(style); } function hideGuestControls() { var roots = [ document.querySelector(".skinHeader"), document.querySelector(".mainDrawer"), document.querySelector(".mainDrawerPanel"), document.querySelector(".pageContainer"), document.body, ].filter(Boolean); var keywords = [ "home", "search", "library", "settings", "download", "share", ]; roots.forEach(function (root) { Array.from( root.querySelectorAll('button, a, [role="button"], [role="menuitem"]') ).forEach(function (node) { if (shouldHideNode(node, keywords)) { node.setAttribute(hiddenAttr, "1"); } }); }); } function shouldHideNode(node, keywords) { if (!node || node.getAttribute(hiddenAttr) === "1") { return false; } var label = getVisibleLabel(node).toLowerCase(); if (!label) { return false; } return keywords.some(function (word) { return label.indexOf(word) >= 0; }); } function getVisibleLabel(node) { return normalizeText( node.getAttribute("aria-label") || node.getAttribute("title") || node.textContent || "" ); } function normalizeText(value) { return String(value || "") .replace(/\s+/g, " ") .trim(); } function isItemGuid(value) { return /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i.test( String(value || "").trim() ); } async function scanForMoreMenuActions() { var user = await getCurrentUser(); if (!isAdministrator(user)) { // Take back anything injected for a previous session rather than only // skipping: a switch inside the SPA leaves the old DOM in place. removeInjectedActions(); return; } var container = findOpenActionContainer(); if (!container || container.querySelector("[" + injectedAttr + '="1"]')) { return; } var itemId = resolveMenuItemId(); if (!itemId || isShareableItem(itemId) !== true) { // Either this is not an item menu we can share from (a person, a // studio, a library...) or the type lookup is still running. // fetchItemType() re-runs this scan once the verdict is known. return; } appendActionSection(container, itemId); } function removeInjectedActions() { Array.prototype.forEach.call( document.querySelectorAll("[" + injectedAttr + '="1"]'), function (node) { node.remove(); } ); } /** * Records which item's "more" menu is about to open. The action sheet is a * detached, body-level element with no link back to the card or row it was * opened from, so the trigger that was just pressed is the only reliable * signal. Any other pointerdown clears the context, so a stale item can never * be picked up by a later menu. */ function rememberMenuContext(target) { var node = target; while (node && node !== document) { if (isMenuTrigger(node)) { menuContext = { itemId: findItemIdInAncestors(node), isPageMenu: !!( node.classList && node.classList.contains("btnMoreCommands") ), ts: Date.now(), }; return; } node = node.parentElement; } menuContext = null; } function isMenuTrigger(node) { if (!node || !node.matches) { return false; } if (node.matches(menuTriggerSelector)) { return true; } return ( node.tagName === "BUTTON" && !!node.querySelector(".material-icons.more_vert") ); } function findItemIdInAncestors(node) { var current = node; while (current && current !== document) { var id = readItemIdFromNode(current); if (id) { return id; } current = current.parentElement; } return null; } /** The item whose menu is open right now, or null when we cannot tell. */ function resolveMenuItemId() { if (!menuContext || Date.now() - menuContext.ts > 15000) { return null; } if (menuContext.itemId) { return menuContext.itemId; } // The detail page's own "..." button sits in the page header, outside any // element carrying the item id, so the route is the item in that case. return menuContext.isPageMenu ? parseItemIdFromUrl() || findItemIdInDocument() : null; } /** * Returns true when the item is a movie, series, season or episode, false when * it is anything else (a person, a studio, a library, a track), and null while * the lookup is still in flight. */ function isShareableItem(itemId) { var id = String(itemId || "").toLowerCase(); if (!isItemGuid(id)) { return false; } if (Object.prototype.hasOwnProperty.call(itemTypeCache, id)) { return shareableItemTypes.indexOf(itemTypeCache[id]) >= 0; } if (!itemTypeInFlight[id]) { itemTypeInFlight[id] = fetchItemType(id); } return null; } function fetchItemType(id) { return Promise.resolve() .then(function () { return ApiClient.getItem(ApiClient.getCurrentUserId(), id); }) .then(function (item) { itemTypeCache[id] = String((item && item.Type) || "").toLowerCase(); }) .catch(function () { itemTypeCache[id] = ""; }) .finally(function () { delete itemTypeInFlight[id]; scheduleWork(); }); } /** * The share tag has to sit in the item's real Tags for Jellyfin's own tag policy * to confine the guest, which means every user would otherwise see a * "sharelinks-..." chip on the title and an entry in the library's tag filter. * Administrators keep seeing them; for everyone else they are taken back out of * the DOM. This is the web UI only - the tag is still in the API payload. */ async function hideShareTagsFromNonAdmins() { // Deliberately only skipped for a confirmed administrator. If the lookup // failed we do not know who this is, and the safe answer is to hide. var user = await getCurrentUser(); if (isAdministrator(user)) { return; } stripShareTagChips(); hideShareTagFilters(); } function stripShareTagChips() { Array.prototype.forEach.call( document.querySelectorAll(".itemTags"), function (container) { var removed = 0; Array.prototype.forEach.call( container.querySelectorAll("a"), function (chip) { if (!shareTagPattern.test(normalizeText(chip.textContent))) { return; } removeChipSeparator(chip); chip.remove(); removed += 1; } ); if (removed > 0 && !container.querySelector("a")) { // Nothing but the "Tags:" prefix would be left over. container.textContent = ""; container.classList.add("hide"); } } ); } /** Drops the ", " text node Jellyfin puts between two tag chips. */ function removeChipSeparator(chip) { var separator = /^\s*,\s*$/; var next = chip.nextSibling; if (next && next.nodeType === 3 && separator.test(next.nodeValue)) { next.parentNode.removeChild(next); return; } var previous = chip.previousSibling; if ( previous && previous.nodeType === 3 && separator.test(previous.nodeValue) ) { previous.parentNode.removeChild(previous); } } function hideShareTagFilters() { Array.prototype.forEach.call( document.querySelectorAll(".chkTagFilter"), function (input) { if (input.getAttribute(tagHiddenAttr) === "1") { return; } if ( !shareTagPattern.test(String(input.getAttribute("data-filter") || "")) ) { return; } input.setAttribute(tagHiddenAttr, "1"); var row = input.closest("label") || input.parentElement; if (row) { row.style.setProperty("display", "none", "important"); } } ); } function isCopyStreamUrlLabel(label) { var value = normalizeText(label).toLowerCase(); if (!value) { return false; } return ( value === copyLabel.toLowerCase() || (value.indexOf("copy") >= 0 && value.indexOf("stream") >= 0 && value.indexOf("url") >= 0) || (value.indexOf("copier") >= 0 && value.indexOf("url") >= 0 && (value.indexOf("flux") >= 0 || value.indexOf("stream") >= 0)) ); } function isItemMenuAction(node) { if (!node || !node.getAttribute) { return false; } var id = String(node.getAttribute("data-id") || "").toLowerCase(); if (id && itemMenuActionIds.indexOf(id) >= 0) { return true; } return isCopyStreamUrlLabel(getVisibleLabel(node)); } function findOpenActionContainer() { var selectors = [".actionSheet", ".actionsheet", '[role="menu"]']; for (var i = 0; i < selectors.length; i += 1) { var nodes = document.querySelectorAll(selectors[i]); for (var j = 0; j < nodes.length; j += 1) { var node = nodes[j]; if (isVisible(node) && looksLikeActionContainer(node)) { return node; } } } return null; } function looksLikeActionContainer(node) { if ( node.querySelector( 'select, input:not([type="checkbox"]):not([type="radio"]), textarea' ) ) { return false; } var items = Array.from( node.querySelectorAll(".actionSheetMenuItem, .actionsheetMenuItem") ).filter(isVisible); if (items.length < 2 || items.length > 40) { return false; } return items.some(function (item) { return isItemMenuAction(item); }); } function findBestActionTemplate(container) { var actions = Array.from( container.querySelectorAll(".actionSheetMenuItem, .actionsheetMenuItem") ) .filter(isVisible) .filter(function (node) { return node.getAttribute(injectedAttr) !== "1"; }); return actions.length ? actions[0] : null; } function isVisible(node) { return !!( node && (node.offsetWidth || node.offsetHeight || node.getClientRects().length) ); } /** * Appends the ShareLink action as its own section at the very bottom of the * menu, separated from the native commands by the same divider Jellyfin * renders between its own groups. */ function appendActionSection(container, itemId) { var template = findBestActionTemplate(container); if (!template) { return; } var scroller = container.querySelector(".actionSheetScroller") || template.parentElement; if (!scroller) { return; } var divider = document.createElement("div"); divider.className = "actionsheetDivider"; divider.setAttribute(injectedAttr, "1"); var action = buildShareAction(template, itemId, container); // Jellyfin puts its Cancel button in a trailing .buttons block inside the // scroller; our section belongs after the last command but above that. var cancelBlock = scroller.querySelector(".buttons"); if (cancelBlock) { cancelBlock.insertAdjacentElement("beforebegin", divider); divider.insertAdjacentElement("afterend", action); return; } scroller.appendChild(divider); scroller.appendChild(action); } function buildShareAction(template, itemId, container) { var action = template.cloneNode(true); action.setAttribute(injectedAttr, "1"); action.setAttribute("type", "button"); action.removeAttribute("id"); action.removeAttribute("href"); action.removeAttribute("onclick"); action.removeAttribute("target"); action.removeAttribute("download"); action.removeAttribute("autoFocus"); // The clone inherits the template's data-id (e.g. 'edit'), which would // duplicate an existing menu item's id and let the action sheet run that // command instead; strip it. action.removeAttribute("data-id"); action.setAttribute("aria-label", actionLabel); action.setAttribute("title", actionLabel); action.dataset.sharelinksItemId = itemId; setActionLabel(action); setActionIcon(action); action.addEventListener( "click", function (event) { event.preventDefault(); event.stopPropagation(); closeActionSheet(container); void createGuestLink(itemId); }, true ); return action; } function setActionLabel(action) { Array.from( action.querySelectorAll(".listItemBodyText.secondary, .listItemAside") ).forEach(function (node) { node.remove(); }); var text = action.querySelector(".actionSheetItemText") || action.querySelector(".listItemBodyText"); if (text) { text.textContent = actionLabel; return; } action.textContent = actionLabel; } function setActionIcon(action) { var icon = action.querySelector(".material-icons"); if (!icon) { icon = document.createElement("span"); icon.setAttribute("aria-hidden", "true"); action.insertBefore(icon, action.firstChild); } icon.className = "actionsheetMenuItemIcon listItemIcon listItemIcon-transparent material-icons share"; icon.style.removeProperty("visibility"); } /** * Dismisses the native action sheet so our dialog is not stacked on top of it. * The sheet's own close button exists only on TV layouts. On desktop, jellyfin-web * closes a sheet when its dialog container gets a mousedown and a click aimed at * the container itself (a click on the backdrop), and ignores Escape outside the * TV layout. Escape stays as the last fallback. */ function closeActionSheet(container) { var closeButton = container.querySelector(".btnCloseActionSheet"); if (closeButton) { closeButton.click(); return; } var dialogContainer = container.closest && container.closest(".dialogContainer"); if (dialogContainer) { dialogContainer.dispatchEvent( new MouseEvent("mousedown", { bubbles: true, cancelable: true }) ); dialogContainer.dispatchEvent( new MouseEvent("click", { bubbles: true, cancelable: true }) ); return; } container.dispatchEvent( new KeyboardEvent("keydown", { key: "Escape", keyCode: 27, which: 27, bubbles: true, cancelable: true, }) ); } function parseItemIdFromUrl() { var sources = [location.hash, location.search, location.href]; for (var i = 0; i < sources.length; i += 1) { var text = sources[i]; var match = text.match(/[?&](?:id|itemId)=([^&#]+)/i); if (match && match[1]) { var decoded = decodeURIComponent(match[1]); if (isItemGuid(decoded)) { return decoded; } } } return null; } function findItemIdInDocument() { var selectors = [ "#itemDetailPage", ".detailPage", ".itemDetailPage", "[data-itemid]", "[data-id]", ]; for (var i = 0; i < selectors.length; i += 1) { var nodes = document.querySelectorAll(selectors[i]); for (var j = 0; j < nodes.length; j += 1) { var id = readItemIdFromNode(nodes[j]); if (id) { return id; } } } return null; } function readItemIdFromNode(node) { if (!node || !node.getAttribute) { return null; } var candidates = [ node.getAttribute("data-itemid"), node.getAttribute("data-id"), node.getAttribute("data-item-id"), ]; for (var i = 0; i < candidates.length; i += 1) { if (isItemGuid(candidates[i])) { return candidates[i]; } } return null; } function isAdministrator(user) { return !!(user && user.Policy && user.Policy.IsAdministrator === true); } function rememberAllowedItemFromRoute() { if (!isDetailsOrPlaybackRoute()) { return; } var itemId = parseItemIdFromUrl() || findItemIdInDocument(); if (itemId) { sessionStorage.setItem(allowedItemStorageKey, itemId); } } function isDetailsOrPlaybackRoute() { return /#\/(?:details|video|playback|list|item)/i.test(location.hash || ""); } var itemVisibilityCache = {}; var itemVisibilityInFlight = {}; function parseCandidateIdsFromUrl() { var sources = [location.hash, location.search, location.href]; var keys = ["id", "seriesId", "parentId", "topParentId"]; var found = []; for (var k = 0; k < keys.length; k += 1) { var pattern = new RegExp("[?&]" + keys[k] + "=([^&#]+)", "i"); for (var i = 0; i < sources.length; i += 1) { var text = sources[i]; var match = text.match(pattern); if (match && match[1]) { var decoded = decodeURIComponent(match[1]); if (isItemGuid(decoded) && found.indexOf(decoded) < 0) { found.push(decoded); } break; } } } return found; } /** * Returns true/false when the verdict for the current location is already * known, or null while it is still being resolved (or the route is not one * we verify). A true/false verdict for a given id is cached so the mutation * observer re-running this on every DOM tick does not spam the API. */ function checkAllowedLocation(allowedItemId) { var hash = location.hash || ""; if (/#\/(?:video|playback)/i.test(hash)) { return true; } if (!/#\/(?:details|item|list)/i.test(hash)) { return false; } var candidates = parseCandidateIdsFromUrl(); var normalizedAllowed = String(allowedItemId || "").toLowerCase(); if (candidates.length === 0) { // Details/list route we could not extract an id from: fall back to // the strict same-item check rather than guessing. return false; } if ( candidates.some(function (id) { return id.toLowerCase() === normalizedAllowed; }) ) { return true; } // None of the candidate ids is the shared item itself. Ask the server // whether the current user (the guest) can actually fetch any of them - // the AllowedTags policy is the real access boundary, so a successful // fetch means the guest is allowed to be here (e.g. a season/episode // inside a shared series or season). return resolveServerVisibility(candidates); } function resolveServerVisibility(candidates) { var pending = false; for (var i = 0; i < candidates.length; i += 1) { var id = candidates[i].toLowerCase(); if (Object.prototype.hasOwnProperty.call(itemVisibilityCache, id)) { if (itemVisibilityCache[id] === true) { return true; } continue; } pending = true; if (!itemVisibilityInFlight[id]) { itemVisibilityInFlight[id] = fetchItemVisibility(id); } } // No definitive "allowed" verdict yet. If at least one candidate is // still being checked, stay neutral (no redirect) until it settles. // Only report a definitive rejection once every candidate has a // cached, negative verdict. return pending ? null : false; } function fetchItemVisibility(id) { return Promise.resolve() .then(function () { var userId = ApiClient.getCurrentUserId(); return ApiClient.getItem(userId, id); }) .then(function () { itemVisibilityCache[id] = true; return true; }) .catch(function () { itemVisibilityCache[id] = false; return false; }) .finally(function () { delete itemVisibilityInFlight[id]; scheduleWork(); }); } function navigateToItem(itemId) { var target = "#/details?id=" + encodeURIComponent(itemId); if (location.hash !== target) { location.hash = target; } } async function createGuestLink(itemId) { if (!isItemGuid(itemId)) { notify(t("cannotDetermineItem")); return; } try { var config = await getConfig(); var user = await getCurrentUser(); if (!isAdministrator(user)) { notify(t("adminOnly")); return; } if (config && config.Enabled === false) { notify(t("disabled")); return; } var result = await chooseExpiryHours( config, function (expiryHours, multiUse) { var payload = { itemId: itemId, expiryHours: expiryHours, oneUse: !multiUse, }; var shareUrlPromise = apiPost( "ShareLinks/Admin/Create", payload ).then(function (response) { var shareUrl = response && (response.ShareUrl || response.shareUrl); if (!shareUrl) { throw new Error(t("noShareUrl")); } return shareUrl; }); var copiedPromise = copyTextWhenReady(shareUrlPromise); return shareUrlPromise.then(function (shareUrl) { return copiedPromise .catch(function () { return false; }) .then(function (copied) { return { shareUrl: shareUrl, copied: copied, multiUse: !!multiUse, }; }); }); } ); if (!result) { return; } showShareResult(result.shareUrl, result.copied, result.multiUse); } catch (error) { notify(extractErrorMessage(error, t("couldNotCreate"))); } } function pad2(value) { return (value < 10 ? "0" : "") + value; } function toLocalDatetimeValue(date) { return ( date.getFullYear() + "-" + pad2(date.getMonth() + 1) + "-" + pad2(date.getDate()) + "T" + pad2(date.getHours()) + ":" + pad2(date.getMinutes()) ); } function chooseExpiryHours(config, onChoose) { var maxHours = clampPositiveInteger(config && config.MaxExpiryHours, 720); var options = durationOptions .filter(function (option) { return option.hours <= maxHours; }) .map(function (option) { return { label: durationLabel(option.hours), hours: option.hours, }; }); var nowMs = Date.now(); var minDate = new Date(nowMs + 5 * 60000); var maxDate = new Date(nowMs + maxHours * 3600000); var defaultDate = new Date(nowMs + 168 * 3600000); if (defaultDate.getTime() > maxDate.getTime()) { defaultDate = maxDate; } return openModal({ title: t("modalTitle"), body: t("modalBody"), options: options, onChoose: onChoose, cancelText: t("cancel"), toggle: { label: t("multiUseLabel"), hint: buildMultiUseHint(config), checked: !(config && config.OneUseDefault !== false), }, datePicker: { min: toLocalDatetimeValue(minDate), max: toLocalDatetimeValue(maxDate), value: toLocalDatetimeValue(defaultDate), maxHours: maxHours, }, }); } function buildMultiUseHint(config) { var hint = t("multiUseHint"); var limit = config ? parseInt(config.MaxConcurrentViewers, 10) : NaN; if (Number.isFinite(limit) && limit > 0) { hint += " " + t("multiUseLimit").replace("{count}", limit); } return hint; } function copyTextWhenReady(textPromise) { if ( navigator.clipboard && navigator.clipboard.write && window.ClipboardItem && window.Blob ) { try { var blobPromise = Promise.resolve(textPromise).then(function (text) { return new Blob([text], { type: "text/plain" }); }); return navigator.clipboard .write([new ClipboardItem({ "text/plain": blobPromise })]) .then(function () { return true; }) .catch(function () { return Promise.resolve(textPromise).then(copyText); }); } catch (error) { return Promise.resolve(textPromise).then(copyText); } } return Promise.resolve(textPromise).then(copyText); } function copyText(text) { if (navigator.clipboard && navigator.clipboard.writeText) { return navigator.clipboard .writeText(text) .then(function () { return true; }) .catch(function () { return fallbackCopy(text); }); } return Promise.resolve(fallbackCopy(text)); } function notify(message) { ensureShareLinksUi(); var toast = document.createElement("div"); toast.className = "sharelinks-toast"; toast.textContent = message; document.body.appendChild(toast); window.setTimeout(function () { toast.classList.add("is-visible"); }, 20); window.setTimeout(function () { toast.classList.remove("is-visible"); window.setTimeout(function () { toast.remove(); }, 180); }, 3600); } function showShareResult(shareUrl, copied, multiUse) { ensureShareLinksUi(); var body = document.createElement("div"); var note = document.createElement("p"); note.className = "sharelinks-note"; note.textContent = copied ? t("copiedNote") : t("notCopiedNote"); body.appendChild(note); if (multiUse) { var multiUseNote = document.createElement("p"); multiUseNote.className = "sharelinks-note"; multiUseNote.textContent = t("resultMultiUseNote"); body.appendChild(multiUseNote); } var urlBox = document.createElement("textarea"); urlBox.className = "sharelinks-url"; urlBox.readOnly = true; urlBox.value = shareUrl; body.appendChild(urlBox); openModalElement({ title: copied ? t("resultCopiedTitle") : t("resultCreatedTitle"), bodyElement: body, actions: [ { label: t("copy"), primary: true, handler: function () { return copyText(shareUrl).then(function (ok) { if (ok) { notify(t("toastCopied")); } else { urlBox.focus(); urlBox.select(); notify(t("toastManual")); } return ok; }); }, }, { label: t("done"), close: true, }, ], onOpen: function () { urlBox.focus(); urlBox.select(); }, }); } function openModal(settings) { var body = document.createElement("div"); var text = document.createElement("p"); text.className = "sharelinks-note"; text.textContent = settings.body; body.appendChild(text); var grid = document.createElement("div"); grid.className = "sharelinks-duration-grid"; body.appendChild(grid); var dateInput = null; if (settings.datePicker) { var dateRow = document.createElement("div"); dateRow.className = "sharelinks-date-row"; var dateLabel = document.createElement("label"); dateLabel.className = "sharelinks-date-label"; dateLabel.textContent = t("dateLabel"); dateInput = document.createElement("input"); dateInput.type = "datetime-local"; dateInput.className = "sharelinks-date-input"; dateInput.min = settings.datePicker.min; dateInput.max = settings.datePicker.max; dateInput.value = settings.datePicker.value; dateLabel.setAttribute("for", "sharelinksExpiryDate"); dateInput.id = "sharelinksExpiryDate"; dateRow.appendChild(dateLabel); dateRow.appendChild(dateInput); body.appendChild(dateRow); } var toggleInput = null; if (settings.toggle) { var toggleRow = document.createElement("label"); toggleRow.className = "sharelinks-toggle-row"; toggleInput = document.createElement("input"); toggleInput.type = "checkbox"; toggleInput.className = "sharelinks-toggle-input"; toggleInput.checked = !!settings.toggle.checked; var toggleText = document.createElement("span"); toggleText.className = "sharelinks-toggle-text"; var toggleLabel = document.createElement("span"); toggleLabel.className = "sharelinks-toggle-label"; toggleLabel.textContent = settings.toggle.label; toggleText.appendChild(toggleLabel); if (settings.toggle.hint) { var toggleHint = document.createElement("span"); toggleHint.className = "sharelinks-toggle-hint"; toggleHint.textContent = settings.toggle.hint; toggleText.appendChild(toggleHint); } toggleRow.appendChild(toggleInput); toggleRow.appendChild(toggleText); body.appendChild(toggleRow); } function toggleChecked() { return !!(toggleInput && toggleInput.checked); } return new Promise(function (resolve) { var modal; var actions = []; if (settings.datePicker) { actions.push({ label: t("create"), primary: true, handler: function () { var raw = dateInput && dateInput.value; if (!raw) { notify(t("pickDateFirst")); return; } var target = new Date(raw); if (isNaN(target.getTime())) { notify(t("dateInvalid")); return; } var hours = Math.ceil((target.getTime() - Date.now()) / 3600000); if (hours < 1) { notify(t("pickFuture")); return; } if ( settings.datePicker.maxHours && hours > settings.datePicker.maxHours ) { hours = settings.datePicker.maxHours; } if (modal) { modal.close(); } resolve( settings.onChoose ? settings.onChoose(hours, toggleChecked()) : hours ); }, }); } actions.push({ label: settings.cancelText || t("cancel"), close: true, handler: function () { resolve(null); }, }); modal = openModalElement({ title: settings.title, bodyElement: body, actions: actions, onDismiss: function () { resolve(null); }, }); settings.options.forEach(function (option) { var button = document.createElement("button"); button.type = "button"; button.className = "sharelinks-duration-button"; button.textContent = option.label; button.addEventListener("click", function () { modal.close(); if (settings.onChoose) { resolve(settings.onChoose(option.hours, toggleChecked())); } else { resolve(option.hours); } }); grid.appendChild(button); }); }); } function openModalElement(settings) { ensureShareLinksUi(); var closed = false; var overlay = document.createElement("div"); overlay.className = "sharelinks-overlay"; overlay.setAttribute("role", "presentation"); var dialog = document.createElement("div"); dialog.className = "sharelinks-dialog"; dialog.setAttribute("role", "dialog"); dialog.setAttribute("aria-modal", "true"); dialog.setAttribute("aria-label", settings.title); overlay.appendChild(dialog); var title = document.createElement("h3"); title.className = "sharelinks-title"; title.textContent = settings.title; dialog.appendChild(title); dialog.appendChild(settings.bodyElement); var actions = document.createElement("div"); actions.className = "sharelinks-actions"; dialog.appendChild(actions); function close() { if (closed) { return; } closed = true; overlay.classList.remove("is-visible"); window.setTimeout(function () { overlay.remove(); }, 160); } (settings.actions || []).forEach(function (action) { var button = document.createElement("button"); button.type = "button"; button.className = action.primary ? "sharelinks-action primary" : "sharelinks-action"; button.textContent = action.label; button.addEventListener("click", function () { var result = action.handler ? action.handler() : null; Promise.resolve(result).finally(function () { if (action.close) { close(); } }); }); actions.appendChild(button); }); overlay.addEventListener("click", function (event) { if (event.target === overlay) { close(); if (settings.onDismiss) { settings.onDismiss(); } } }); document.addEventListener( "keydown", function onKeyDown(event) { if (event.key === "Escape" && !closed) { document.removeEventListener("keydown", onKeyDown, true); close(); if (settings.onDismiss) { settings.onDismiss(); } } }, true ); document.body.appendChild(overlay); window.setTimeout(function () { overlay.classList.add("is-visible"); var firstButton = overlay.querySelector("button:not([disabled])"); if (firstButton) { firstButton.focus(); } if (settings.onOpen) { settings.onOpen(); } }, 20); return { close: close, element: overlay }; } function ensureShareLinksUi() { if (document.getElementById("ShareLinksUiStyle")) { return; } var style = document.createElement("style"); style.id = "ShareLinksUiStyle"; style.textContent = [ ".sharelinks-overlay{position:fixed;inset:0;z-index:999999;background:rgba(0,0,0,.58);display:grid;place-items:center;padding:24px;opacity:0;transition:opacity .16s ease;}", ".sharelinks-overlay.is-visible{opacity:1;}", ".sharelinks-dialog{width:min(520px,100%);background:var(--background-color,#202020);color:var(--text-color,#fff);box-shadow:0 18px 60px rgba(0,0,0,.45);border:1px solid rgba(255,255,255,.12);border-radius:8px;padding:22px;}", ".sharelinks-title{font-size:1.25rem;line-height:1.3;margin:0 0 14px;font-weight:600;}", ".sharelinks-note{margin:0 0 16px;color:var(--text-secondary-color,#cfcfcf);line-height:1.45;}", ".sharelinks-duration-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:4px;}", ".sharelinks-duration-button,.sharelinks-action{border:0;border-radius:6px;background:rgba(255,255,255,.12);color:inherit;padding:10px 12px;min-height:42px;cursor:pointer;font:inherit;}", ".sharelinks-duration-button:hover:not(:disabled),.sharelinks-action:hover{background:rgba(255,255,255,.18);}", ".sharelinks-duration-button:disabled{opacity:.35;cursor:not-allowed;}", ".sharelinks-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:18px;}", ".sharelinks-action.primary{background:var(--theme-primary-color,#00a4dc);color:#fff;}", ".sharelinks-url{width:100%;min-height:88px;box-sizing:border-box;border:1px solid rgba(255,255,255,.2);border-radius:6px;background:rgba(0,0,0,.18);color:inherit;padding:10px;font:inherit;resize:vertical;}", ".sharelinks-date-row{margin-top:18px;display:flex;flex-direction:column;gap:8px;}", ".sharelinks-date-label{color:var(--text-secondary-color,#cfcfcf);font-size:.92rem;line-height:1.4;}", ".sharelinks-date-input{width:100%;box-sizing:border-box;border:1px solid rgba(255,255,255,.2);border-radius:6px;background:rgba(0,0,0,.18);color:inherit;padding:10px 12px;min-height:42px;font:inherit;color-scheme:dark;}", ".sharelinks-toggle-row{display:flex;align-items:flex-start;gap:10px;margin-top:18px;padding-top:16px;border-top:1px solid rgba(255,255,255,.12);cursor:pointer;}", ".sharelinks-toggle-input{margin:2px 0 0;width:18px;height:18px;flex:0 0 auto;accent-color:var(--theme-primary-color,#00a4dc);cursor:pointer;}", ".sharelinks-toggle-text{display:flex;flex-direction:column;gap:4px;}", ".sharelinks-toggle-label{line-height:1.35;}", ".sharelinks-toggle-hint{color:var(--text-secondary-color,#cfcfcf);font-size:.88rem;line-height:1.4;}", ".sharelinks-toast{position:fixed;left:24px;bottom:24px;z-index:1000000;max-width:min(460px,calc(100vw - 48px));background:rgba(24,24,24,.96);color:#fff;border:1px solid rgba(255,255,255,.14);border-radius:6px;padding:11px 14px;box-shadow:0 10px 30px rgba(0,0,0,.35);opacity:0;transform:translateY(8px);transition:opacity .18s ease,transform .18s ease;}", ".sharelinks-toast.is-visible{opacity:1;transform:translateY(0);}", "@media (max-width:520px){.sharelinks-duration-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.sharelinks-dialog{padding:18px;}.sharelinks-actions{justify-content:stretch;}.sharelinks-action{flex:1;}}", ].join(""); document.head.appendChild(style); } function fallbackCopy(text) { var textarea = document.createElement("textarea"); textarea.value = text; textarea.setAttribute("readonly", "readonly"); textarea.style.position = "fixed"; textarea.style.top = "-1000px"; textarea.style.left = "-1000px"; document.body.appendChild(textarea); textarea.focus(); textarea.select(); var copied = false; try { copied = document.execCommand("copy"); } catch (error) { copied = false; } textarea.remove(); return copied; } function clampPositiveInteger(value, fallback) { var parsed = parseInt(value, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } function extractErrorMessage(error, fallback) { if (!error) { return fallback; } if (typeof error === "string") { return error; } if (error.responseJSON && error.responseJSON.error) { return error.responseJSON.error; } if (error.responseText) { return error.responseText; } if (error.message) { return error.message; } return fallback; } start(); })();