(function () { var pluginId = '68540b76-ee74-436d-85ff-2abc884bbea6'; var copyLabel = 'Copy Stream URL'; var actionLabel = 'ShareLink'; var clientVersion = '1.0.3-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']; 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.', 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.', 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"],' + ' 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. * Clicking the sheet's own close button keeps Jellyfin's animation and state * handling; Escape is the fallback when the sheet renders without one. */ function closeActionSheet(container) { var closeButton = container.querySelector('.btnCloseActionSheet'); if (closeButton) { closeButton.click(); 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: t('multiUseHint'), checked: !(config && config.OneUseDefault !== false) }, datePicker: { min: toLocalDatetimeValue(minDate), max: toLocalDatetimeValue(maxDate), value: toLocalDatetimeValue(defaultDate), maxHours: maxHours } }); } 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(); })();