Fichiers
jellyfin-plugin-sharelinks/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js
T
Francois CB e419cbbce4 Merge pull request #9 from Franciskid/security/harden-redemption-and-expiry
Harden redemption, expiry limits, token storage and guest sign-in
2026-07-26 20:44:42 +02:00

1520 lignes
54 KiB
JavaScript

(function () {
var pluginId = '68540b76-ee74-436d-85ff-2abc884bbea6';
var copyLabel = 'Copy Stream URL';
var actionLabel = 'ShareLink';
var clientVersion = '1.0.2-ui-3';
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.',
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.',
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) {
var payload = {
itemId: itemId,
expiryHours: expiryHours,
oneUse: config && config.OneUseDefault !== undefined ? !!config.OneUseDefault : true
};
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
};
});
});
});
if (!result) {
return;
}
showShareResult(result.shareUrl, result.copied);
} 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'),
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) {
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);
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);
}
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) : 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));
} 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-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();
})();