Fichiers
jellyfin-plugin-sharelinks/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js
T
Franciskid 224a79f7e5 cap how many people can watch one multi-use link at once
New setting, ten by default, zero for no limit. Single-use links are unaffected,
they are one viewer by definition.

The catch is what happens at the ceiling. Jellyfin throws SecurityException once a
user is at MaxActiveSessions, and that was landing in the generic handler, which
marks the record failed and runs cleanup, which deletes the guest account. So
without care, adding a ceiling would mean the eleventh person to open a link kicks
out the ten already watching and destroys the link. Capacity is caught separately
now: the record goes back to the state it was in, nothing is torn down, and the
new arrival gets a 503 page inviting them to try again.

Worth being honest that this caps how many people can start watching at once, not
how many ever get in: each redemption issues its own session token that keeps
working until the link is revoked or expires. Revoke is still the hard stop.

README picks up the multi-use option, the new setting, and a section on what a
multi-use link does and does not protect, plus the known limits around the token
in the query string, the unthrottled redeem endpoint, and the tag being hidden in
the web UI only.
2026-07-26 21:22:20 +02:00

1590 lignes
58 KiB
JavaScript

(function () {
var pluginId = '68540b76-ee74-436d-85ff-2abc884bbea6';
var copyLabel = 'Copy Stream URL';
var actionLabel = 'ShareLink';
var clientVersion = '1.0.3-ui-2';
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.',
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"],'
+ ' 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: 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();
})();