Fichiers
jellyfin-plugin-sharelinks/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js
T

1893 lignes
55 KiB
JavaScript

(function () {
var pluginId = "68540b76-ee74-436d-85ff-2abc884bbea6";
var copyLabel = "Copy Stream URL";
var clientVersion = "1.0.6-ui-1";
var allowedItemStorageKey = "sharelinks.allowedItemId";
var guestClassName = "sharelinks-guest";
var hiddenAttr = "data-sharelinks-hidden";
var injectedAttr = "data-sharelinks-injected";
var configPromise = null;
var userPromise = null;
var userPromiseUserId = null;
var guestStatePromise = null;
var guestStatePromiseUserId = null;
var booted = false;
var scanQueued = false;
var bootRetry = null;
var observer = null;
var menuContext = null;
var shareableItemTypes = [
"movie",
"series",
"season",
"episode",
"video",
"photo",
"boxset",
];
var menuTriggerSelector =
'[data-action="menu"], .btnMoreCommands, .btnCardOptions, .cardOverlayButton';
var itemTypeCache = {};
var itemTypeInFlight = {};
var shareTagPattern = /^sharelinks-[0-9a-f]{32}$/i;
var tagHiddenAttr = "data-sharelinks-tag-hidden";
var historyPatched = false;
var itemMenuActionIds = [
"resume",
"playallfromhere",
"queue",
"queuenext",
"shuffle",
"instantmix",
"multiselect",
"addtocollection",
"addtoplaylist",
"editplaylist",
"removefromcollection",
"removefromplaylist",
"movetotop",
"movetobottom",
"download",
"downloadall",
"copy-stream",
"delete",
"edit",
"editimages",
"editsubtitles",
"editlyrics",
"identify",
"moremediainfo",
"refresh",
"record",
"canceltimer",
"cancelseriestimer",
"share",
"album",
"artist",
"lyrics",
// Older Jellyfin web builds used these ids for the same commands.
"moreinfo",
"mediainfo",
"editmetadata",
"playlist",
"copystream",
];
var durationOptions = [
{ label: "1 hour", hours: 1 },
{ label: "2 hours", hours: 2 },
{ label: "4 hours", hours: 4 },
{ label: "6 hours", hours: 6 },
{ label: "12 hours", hours: 12 },
{ label: "1 day", hours: 24 },
{ label: "2 days", hours: 48 },
{ label: "7 days", hours: 168 },
];
function isFrench() {
var lang =
document.documentElement.getAttribute("lang") ||
(navigator && (navigator.language || navigator.userLanguage)) ||
"en";
return /^fr/i.test(lang);
}
var STRINGS = {
en: {
modalTitle: "Create guest link",
modalBody: "Choose how long this link should stay valid.",
dateLabel: "Or pick an exact expiry date and time:",
create: "Create",
cancel: "Cancel",
copy: "Copy",
done: "Done",
pickDateFirst: "Pick a date and time first.",
dateInvalid: "That date is not valid.",
pickFuture: "Pick a time in the future.",
multiUseLabel: "Let several people use this link",
multiUseHint:
"The link keeps working for anyone you send it to until it expires, instead of dying once the first person opens it.",
multiUseLimit: "Up to {count} of them can watch at the same time.",
resultMultiUseNote:
"Anyone you send this link to can open it until it expires.",
cannotDetermineItem:
"Could not determine which item to share. Open the item page and retry.",
adminOnly: "ShareLinks is available to administrators only.",
disabled: "ShareLinks is disabled.",
noShareUrl: "The server did not return a share URL.",
couldNotCreate: "Could not create a guest link.",
copiedNote: "The link was copied to your clipboard.",
notCopiedNote:
"The link was created, but the browser blocked automatic clipboard access.",
resultCopiedTitle: "Share link copied",
resultCreatedTitle: "Share link created",
toastCopied: "Share link copied.",
toastManual: "Select and copy the link manually.",
hour: "hour",
hours: "hours",
day: "day",
days: "days",
shareAction: "ShareLink",
},
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",
shareAction: "Partager un lien",
},
};
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 hideEmptyMoreButtonOnBoxSet() {
var button = document.querySelector(".btnMoreCommands.detailButton");
if (!button) {
return;
}
var itemId = parseItemIdFromUrl() || findItemIdInDocument();
var id = itemId ? itemId.toLowerCase() : "";
var type =
id && Object.prototype.hasOwnProperty.call(itemTypeCache, id)
? itemTypeCache[id]
: null;
if (type === "boxset") {
button.setAttribute("data-sharelinks-hidden-boxset", "1");
button.style.setProperty("display", "none", "important");
} else if (button.getAttribute("data-sharelinks-hidden-boxset") === "1") {
// Route changed away from a BoxSet page (SPA navigation): restore it.
button.removeAttribute("data-sharelinks-hidden-boxset");
button.style.removeProperty("display");
}
}
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" ||
dataId === "removefromcollection" ||
dataId === "removefromplaylist" ||
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();
var pageItemId = parseItemIdFromUrl() || findItemIdInDocument();
if (pageItemId && isItemGuid(pageItemId)) {
var id = pageItemId.toLowerCase();
if (
!Object.prototype.hasOwnProperty.call(itemTypeCache, id) &&
!itemTypeInFlight[id]
) {
itemTypeInFlight[id] = fetchItemType(id); // populates itemTypeCache, then calls scheduleWork()
}
}
hideEmptyMoreButtonOnBoxSet();
// UX-only lockdown: the guest user's real access boundary is still the
// server-side policy and item tags. This just keeps the web client out
// of the user's way, while still letting the guest browse into the
// shared tree (series -> season -> episode) when the server says the
// item is visible to them.
if (!context.allowedItemId) {
return;
}
var verdict = await checkAllowedLocation(context.allowedItemId);
if (verdict === false) {
navigateToItem(context.allowedItemId);
}
// verdict === true: allowed, do nothing.
// verdict === null: check still in flight or route is not item-scoped
// in a way we can verify yet; do nothing to avoid flicker.
}
async function getGuestContext() {
var config = await getConfig();
var user = await getCurrentUser();
var state = await getGuestState();
var prefix =
config && config.GuestUsernamePrefix
? String(config.GuestUsernamePrefix)
: "share-";
var username = user && user.Name ? String(user.Name) : "";
var lockdownEnabled = config && config.GuestModeLockdownEnabled !== false;
if (state && state.lockdownEnabled === false) {
lockdownEnabled = false;
}
var locked =
lockdownEnabled &&
((username && username.indexOf(prefix) === 0) ||
!!(
state &&
(state.IsGuest === true ||
state.isGuest === true ||
state.GuestUserId ||
state.guestUserId)
));
return {
locked: locked,
allowedItemId:
extractAllowedItemId(state) ||
sessionStorage.getItem(allowedItemStorageKey) ||
null,
username: username,
prefix: prefix,
lockdownEnabled: lockdownEnabled,
hiddenSelectors:
(state && (state.HiddenSelectors || state.hiddenSelectors)) || "",
};
}
function extractAllowedItemId(state) {
if (!state) {
return null;
}
return (
state.AllowedItemId ||
state.allowedItemId ||
state.ItemId ||
state.itemId ||
state.ShareItemId ||
state.shareItemId ||
null
);
}
function ensureGuestStyle() {
if (document.body.classList.contains(guestClassName)) {
return;
}
document.body.classList.add(guestClassName);
if (document.getElementById("ShareLinksGuestStyle")) {
return;
}
var style = document.createElement("style");
style.id = "ShareLinksGuestStyle";
style.textContent =
"body." +
guestClassName +
" [" +
hiddenAttr +
'="1"],' +
" body." +
guestClassName +
" .headerHomeButton," +
" body." +
guestClassName +
" .mainDrawerButton," +
" body." +
guestClassName +
" .headerSearchButton," +
" body." +
guestClassName +
' [data-action="addtoplaylist"],' +
" body." +
guestClassName +
' [data-action="addtocollection"],' +
" body." +
guestClassName +
' [data-id="playlist"],' +
// The Modern layout (the default from Jellyfin 12) renders its own header
// instead of .skinHeader, so its controls need their own selectors here.
" body." +
guestClassName +
' .MuiAppBar-root a[href^="#/"],' +
" body." +
guestClassName +
' .MuiAppBar-root [aria-controls="app-user-menu"],' +
" body." +
guestClassName +
" .MuiAppBar-root .MuiToolbar-root > button:first-child," +
" body." +
guestClassName +
' [data-id="addtocollection"] { display: none !important; }' +
// Cards stay clickable so guests can navigate season/episode cards on a shared series;
// checkAllowedLocation() verifies the destination server-side and redirects if disallowed.
" body." +
guestClassName +
" #castCollapsible a," +
" body." +
guestClassName +
" .detailsGroupItem a," +
" body." +
guestClassName +
" .genresGroup a," +
" body." +
guestClassName +
" .studiosGroup a," +
" body." +
guestClassName +
" .itemTags a," +
" body." +
guestClassName +
" .mediaInfoItem a { pointer-events: none !important; cursor: default !important; }";
document.head.appendChild(style);
}
function ensurePluginHideStyle(selectors) {
var value = String(selectors || "")
.split(",")
.map(function (part) {
return part.trim();
})
.filter(Boolean);
var existing = document.getElementById("ShareLinksPluginHideStyle");
if (value.length === 0) {
if (existing) {
existing.remove();
}
return;
}
var css = value.join(", ") + " { display: none !important; }";
if (existing) {
if (existing.textContent !== css) {
existing.textContent = css;
}
return;
}
var style = document.createElement("style");
style.id = "ShareLinksPluginHideStyle";
style.textContent = css;
document.head.appendChild(style);
}
function hideGuestControls() {
var roots = [
document.querySelector(".skinHeader"),
document.querySelector(".mainDrawer"),
document.querySelector(".mainDrawerPanel"),
document.querySelector(".pageContainer"),
document.body,
].filter(Boolean);
var keywords = [
"home",
"search",
"library",
"settings",
"download",
"share",
];
roots.forEach(function (root) {
Array.from(
root.querySelectorAll('button, a, [role="button"], [role="menuitem"]')
).forEach(function (node) {
if (node.closest(".card")) {
// Never hide anything inside a media card: a movie/show title or
// poster link is not navigation, and titles like "Spider-Man: Far
// From Home" can accidentally contain a keyword like "home".
return;
}
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", t("shareAction"));
action.setAttribute("title", t("shareAction"));
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 = t("shareAction");
return;
}
action.textContent = t("shareAction");
}
function setActionIcon(action) {
var icon = action.querySelector(".material-icons");
if (!icon) {
icon = document.createElement("span");
icon.setAttribute("aria-hidden", "true");
action.insertBefore(icon, action.firstChild);
}
icon.className =
"actionsheetMenuItemIcon listItemIcon listItemIcon-transparent material-icons share";
icon.style.removeProperty("visibility");
}
/**
* Dismisses the native action sheet so our dialog is not stacked on top of it.
* The sheet's own close button exists only on TV layouts. On desktop, jellyfin-web
* closes a sheet when its dialog container gets a mousedown and a click aimed at
* the container itself (a click on the backdrop), and ignores Escape outside the
* TV layout. Escape stays as the last fallback.
*/
function closeActionSheet(container) {
var closeButton = container.querySelector(".btnCloseActionSheet");
if (closeButton) {
closeButton.click();
return;
}
var dialogContainer =
container.closest && container.closest(".dialogContainer");
if (dialogContainer) {
dialogContainer.dispatchEvent(
new MouseEvent("mousedown", { bubbles: true, cancelable: true })
);
dialogContainer.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true })
);
return;
}
container.dispatchEvent(
new KeyboardEvent("keydown", {
key: "Escape",
keyCode: 27,
which: 27,
bubbles: true,
cancelable: true,
})
);
}
function parseItemIdFromUrl() {
var sources = [location.hash, location.search, location.href];
for (var i = 0; i < sources.length; i += 1) {
var text = sources[i];
var match = text.match(/[?&](?:id|itemId)=([^&#]+)/i);
if (match && match[1]) {
var decoded = decodeURIComponent(match[1]);
if (isItemGuid(decoded)) {
return decoded;
}
}
}
return null;
}
function findItemIdInDocument() {
var selectors = [
"#itemDetailPage",
".detailPage",
".itemDetailPage",
"[data-itemid]",
"[data-id]",
];
for (var i = 0; i < selectors.length; i += 1) {
var nodes = document.querySelectorAll(selectors[i]);
for (var j = 0; j < nodes.length; j += 1) {
var id = readItemIdFromNode(nodes[j]);
if (id) {
return id;
}
}
}
return null;
}
function readItemIdFromNode(node) {
if (!node || !node.getAttribute) {
return null;
}
var candidates = [
node.getAttribute("data-itemid"),
node.getAttribute("data-id"),
node.getAttribute("data-item-id"),
];
for (var i = 0; i < candidates.length; i += 1) {
if (isItemGuid(candidates[i])) {
return candidates[i];
}
}
return null;
}
function isAdministrator(user) {
return !!(user && user.Policy && user.Policy.IsAdministrator === true);
}
function rememberAllowedItemFromRoute() {
if (!isDetailsOrPlaybackRoute()) {
return;
}
var itemId = parseItemIdFromUrl() || findItemIdInDocument();
if (itemId) {
sessionStorage.setItem(allowedItemStorageKey, itemId);
}
}
function isDetailsOrPlaybackRoute() {
return /#\/(?:details|video|playback|list|item)/i.test(location.hash || "");
}
var itemVisibilityCache = {};
var itemVisibilityInFlight = {};
function parseCandidateIdsFromUrl() {
var sources = [location.hash, location.search, location.href];
var keys = ["id", "seriesId", "parentId", "topParentId"];
var found = [];
for (var k = 0; k < keys.length; k += 1) {
var pattern = new RegExp("[?&]" + keys[k] + "=([^&#]+)", "i");
for (var i = 0; i < sources.length; i += 1) {
var text = sources[i];
var match = text.match(pattern);
if (match && match[1]) {
var decoded = decodeURIComponent(match[1]);
if (isItemGuid(decoded) && found.indexOf(decoded) < 0) {
found.push(decoded);
}
break;
}
}
}
return found;
}
/**
* Returns true/false when the verdict for the current location is already
* known, or null while it is still being resolved (or the route is not one
* we verify). A true/false verdict for a given id is cached so the mutation
* observer re-running this on every DOM tick does not spam the API.
*/
function checkAllowedLocation(allowedItemId) {
var hash = location.hash || "";
if (/#\/(?:video|playback)/i.test(hash)) {
return true;
}
if (!/#\/(?:details|item|list)/i.test(hash)) {
return false;
}
var candidates = parseCandidateIdsFromUrl();
var normalizedAllowed = String(allowedItemId || "").toLowerCase();
if (candidates.length === 0) {
// Details/list route we could not extract an id from: fall back to
// the strict same-item check rather than guessing.
return false;
}
if (
candidates.some(function (id) {
return id.toLowerCase() === normalizedAllowed;
})
) {
return true;
}
// None of the candidate ids is the shared item itself. Ask the server
// whether the current user (the guest) can actually fetch any of them -
// the AllowedTags policy is the real access boundary, so a successful
// fetch means the guest is allowed to be here (e.g. a season/episode
// inside a shared series or season).
return resolveServerVisibility(candidates);
}
function resolveServerVisibility(candidates) {
var pending = false;
for (var i = 0; i < candidates.length; i += 1) {
var id = candidates[i].toLowerCase();
if (Object.prototype.hasOwnProperty.call(itemVisibilityCache, id)) {
if (itemVisibilityCache[id] === true) {
return true;
}
continue;
}
pending = true;
if (!itemVisibilityInFlight[id]) {
itemVisibilityInFlight[id] = fetchItemVisibility(id);
}
}
// No definitive "allowed" verdict yet. If at least one candidate is
// still being checked, stay neutral (no redirect) until it settles.
// Only report a definitive rejection once every candidate has a
// cached, negative verdict.
return pending ? null : false;
}
function fetchItemVisibility(id) {
return Promise.resolve()
.then(function () {
var userId = ApiClient.getCurrentUserId();
return ApiClient.getItem(userId, id);
})
.then(function () {
itemVisibilityCache[id] = true;
return true;
})
.catch(function () {
itemVisibilityCache[id] = false;
return false;
})
.finally(function () {
delete itemVisibilityInFlight[id];
scheduleWork();
});
}
function navigateToItem(itemId) {
var target = "#/details?id=" + encodeURIComponent(itemId);
if (location.hash !== target) {
location.hash = target;
}
}
async function createGuestLink(itemId) {
if (!isItemGuid(itemId)) {
notify(t("cannotDetermineItem"));
return;
}
try {
var config = await getConfig();
var user = await getCurrentUser();
if (!isAdministrator(user)) {
notify(t("adminOnly"));
return;
}
if (config && config.Enabled === false) {
notify(t("disabled"));
return;
}
var result = await chooseExpiryHours(
config,
function (expiryHours, multiUse) {
var payload = {
itemId: itemId,
expiryHours: expiryHours,
oneUse: !multiUse,
};
var shareUrlPromise = apiPost(
"ShareLinks/Admin/Create",
payload
).then(function (response) {
var shareUrl = response && (response.ShareUrl || response.shareUrl);
if (!shareUrl) {
throw new Error(t("noShareUrl"));
}
return shareUrl;
});
var copiedPromise = copyTextWhenReady(shareUrlPromise);
return shareUrlPromise.then(function (shareUrl) {
return copiedPromise
.catch(function () {
return false;
})
.then(function (copied) {
return {
shareUrl: shareUrl,
copied: copied,
multiUse: !!multiUse,
};
});
});
}
);
if (!result) {
return;
}
showShareResult(result.shareUrl, result.copied, result.multiUse);
} catch (error) {
notify(extractErrorMessage(error, t("couldNotCreate")));
}
}
function pad2(value) {
return (value < 10 ? "0" : "") + value;
}
function toLocalDatetimeValue(date) {
return (
date.getFullYear() +
"-" +
pad2(date.getMonth() + 1) +
"-" +
pad2(date.getDate()) +
"T" +
pad2(date.getHours()) +
":" +
pad2(date.getMinutes())
);
}
function chooseExpiryHours(config, onChoose) {
var maxHours = clampPositiveInteger(config && config.MaxExpiryHours, 720);
var options = durationOptions
.filter(function (option) {
return option.hours <= maxHours;
})
.map(function (option) {
return {
label: durationLabel(option.hours),
hours: option.hours,
};
});
var nowMs = Date.now();
var minDate = new Date(nowMs + 5 * 60000);
var maxDate = new Date(nowMs + maxHours * 3600000);
var defaultDate = new Date(nowMs + 168 * 3600000);
if (defaultDate.getTime() > maxDate.getTime()) {
defaultDate = maxDate;
}
return openModal({
title: t("modalTitle"),
body: t("modalBody"),
options: options,
onChoose: onChoose,
cancelText: t("cancel"),
toggle: {
label: t("multiUseLabel"),
hint: buildMultiUseHint(config),
checked: !(config && config.OneUseDefault !== false),
},
datePicker: {
min: toLocalDatetimeValue(minDate),
max: toLocalDatetimeValue(maxDate),
value: toLocalDatetimeValue(defaultDate),
maxHours: maxHours,
},
});
}
function buildMultiUseHint(config) {
var hint = t("multiUseHint");
var limit = config ? parseInt(config.MaxConcurrentViewers, 10) : NaN;
if (Number.isFinite(limit) && limit > 0) {
hint += " " + t("multiUseLimit").replace("{count}", limit);
}
return hint;
}
function copyTextWhenReady(textPromise) {
if (
navigator.clipboard &&
navigator.clipboard.write &&
window.ClipboardItem &&
window.Blob
) {
try {
var blobPromise = Promise.resolve(textPromise).then(function (text) {
return new Blob([text], { type: "text/plain" });
});
return navigator.clipboard
.write([new ClipboardItem({ "text/plain": blobPromise })])
.then(function () {
return true;
})
.catch(function () {
return Promise.resolve(textPromise).then(copyText);
});
} catch (error) {
return Promise.resolve(textPromise).then(copyText);
}
}
return Promise.resolve(textPromise).then(copyText);
}
function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard
.writeText(text)
.then(function () {
return true;
})
.catch(function () {
return fallbackCopy(text);
});
}
return Promise.resolve(fallbackCopy(text));
}
function notify(message) {
ensureShareLinksUi();
var toast = document.createElement("div");
toast.className = "sharelinks-toast";
toast.textContent = message;
document.body.appendChild(toast);
window.setTimeout(function () {
toast.classList.add("is-visible");
}, 20);
window.setTimeout(function () {
toast.classList.remove("is-visible");
window.setTimeout(function () {
toast.remove();
}, 180);
}, 3600);
}
function showShareResult(shareUrl, copied, multiUse) {
ensureShareLinksUi();
var body = document.createElement("div");
var note = document.createElement("p");
note.className = "sharelinks-note";
note.textContent = copied ? t("copiedNote") : t("notCopiedNote");
body.appendChild(note);
if (multiUse) {
var multiUseNote = document.createElement("p");
multiUseNote.className = "sharelinks-note";
multiUseNote.textContent = t("resultMultiUseNote");
body.appendChild(multiUseNote);
}
var urlBox = document.createElement("textarea");
urlBox.className = "sharelinks-url";
urlBox.readOnly = true;
urlBox.value = shareUrl;
body.appendChild(urlBox);
openModalElement({
title: copied ? t("resultCopiedTitle") : t("resultCreatedTitle"),
bodyElement: body,
actions: [
{
label: t("copy"),
primary: true,
handler: function () {
return copyText(shareUrl).then(function (ok) {
if (ok) {
notify(t("toastCopied"));
} else {
urlBox.focus();
urlBox.select();
notify(t("toastManual"));
}
return ok;
});
},
},
{
label: t("done"),
close: true,
},
],
onOpen: function () {
urlBox.focus();
urlBox.select();
},
});
}
function openModal(settings) {
var body = document.createElement("div");
var text = document.createElement("p");
text.className = "sharelinks-note";
text.textContent = settings.body;
body.appendChild(text);
var grid = document.createElement("div");
grid.className = "sharelinks-duration-grid";
body.appendChild(grid);
var dateInput = null;
if (settings.datePicker) {
var dateRow = document.createElement("div");
dateRow.className = "sharelinks-date-row";
var dateLabel = document.createElement("label");
dateLabel.className = "sharelinks-date-label";
dateLabel.textContent = t("dateLabel");
dateInput = document.createElement("input");
dateInput.type = "datetime-local";
dateInput.className = "sharelinks-date-input";
dateInput.min = settings.datePicker.min;
dateInput.max = settings.datePicker.max;
dateInput.value = settings.datePicker.value;
dateLabel.setAttribute("for", "sharelinksExpiryDate");
dateInput.id = "sharelinksExpiryDate";
dateRow.appendChild(dateLabel);
dateRow.appendChild(dateInput);
body.appendChild(dateRow);
}
var toggleInput = null;
if (settings.toggle) {
var toggleRow = document.createElement("label");
toggleRow.className = "sharelinks-toggle-row";
toggleInput = document.createElement("input");
toggleInput.type = "checkbox";
toggleInput.className = "sharelinks-toggle-input";
toggleInput.checked = !!settings.toggle.checked;
var toggleText = document.createElement("span");
toggleText.className = "sharelinks-toggle-text";
var toggleLabel = document.createElement("span");
toggleLabel.className = "sharelinks-toggle-label";
toggleLabel.textContent = settings.toggle.label;
toggleText.appendChild(toggleLabel);
if (settings.toggle.hint) {
var toggleHint = document.createElement("span");
toggleHint.className = "sharelinks-toggle-hint";
toggleHint.textContent = settings.toggle.hint;
toggleText.appendChild(toggleHint);
}
toggleRow.appendChild(toggleInput);
toggleRow.appendChild(toggleText);
body.appendChild(toggleRow);
}
function toggleChecked() {
return !!(toggleInput && toggleInput.checked);
}
return new Promise(function (resolve) {
var modal;
var actions = [];
if (settings.datePicker) {
actions.push({
label: t("create"),
primary: true,
handler: function () {
var raw = dateInput && dateInput.value;
if (!raw) {
notify(t("pickDateFirst"));
return;
}
var target = new Date(raw);
if (isNaN(target.getTime())) {
notify(t("dateInvalid"));
return;
}
var hours = Math.ceil((target.getTime() - Date.now()) / 3600000);
if (hours < 1) {
notify(t("pickFuture"));
return;
}
if (
settings.datePicker.maxHours &&
hours > settings.datePicker.maxHours
) {
hours = settings.datePicker.maxHours;
}
if (modal) {
modal.close();
}
resolve(
settings.onChoose
? settings.onChoose(hours, toggleChecked())
: hours
);
},
});
}
actions.push({
label: settings.cancelText || t("cancel"),
close: true,
handler: function () {
resolve(null);
},
});
modal = openModalElement({
title: settings.title,
bodyElement: body,
actions: actions,
onDismiss: function () {
resolve(null);
},
});
settings.options.forEach(function (option) {
var button = document.createElement("button");
button.type = "button";
button.className = "sharelinks-duration-button";
button.textContent = option.label;
button.addEventListener("click", function () {
modal.close();
if (settings.onChoose) {
resolve(settings.onChoose(option.hours, toggleChecked()));
} else {
resolve(option.hours);
}
});
grid.appendChild(button);
});
});
}
function openModalElement(settings) {
ensureShareLinksUi();
var closed = false;
var overlay = document.createElement("div");
overlay.className = "sharelinks-overlay";
overlay.setAttribute("role", "presentation");
var dialog = document.createElement("div");
dialog.className = "sharelinks-dialog";
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
dialog.setAttribute("aria-label", settings.title);
overlay.appendChild(dialog);
var title = document.createElement("h3");
title.className = "sharelinks-title";
title.textContent = settings.title;
dialog.appendChild(title);
dialog.appendChild(settings.bodyElement);
var actions = document.createElement("div");
actions.className = "sharelinks-actions";
dialog.appendChild(actions);
function close() {
if (closed) {
return;
}
closed = true;
overlay.classList.remove("is-visible");
window.setTimeout(function () {
overlay.remove();
}, 160);
}
(settings.actions || []).forEach(function (action) {
var button = document.createElement("button");
button.type = "button";
button.className = action.primary
? "sharelinks-action primary"
: "sharelinks-action";
button.textContent = action.label;
button.addEventListener("click", function () {
var result = action.handler ? action.handler() : null;
Promise.resolve(result).finally(function () {
if (action.close) {
close();
}
});
});
actions.appendChild(button);
});
overlay.addEventListener("click", function (event) {
if (event.target === overlay) {
close();
if (settings.onDismiss) {
settings.onDismiss();
}
}
});
document.addEventListener(
"keydown",
function onKeyDown(event) {
if (event.key === "Escape" && !closed) {
document.removeEventListener("keydown", onKeyDown, true);
close();
if (settings.onDismiss) {
settings.onDismiss();
}
}
},
true
);
document.body.appendChild(overlay);
window.setTimeout(function () {
overlay.classList.add("is-visible");
var firstButton = overlay.querySelector("button:not([disabled])");
if (firstButton) {
firstButton.focus();
}
if (settings.onOpen) {
settings.onOpen();
}
}, 20);
return { close: close, element: overlay };
}
function ensureShareLinksUi() {
if (document.getElementById("ShareLinksUiStyle")) {
return;
}
var style = document.createElement("style");
style.id = "ShareLinksUiStyle";
style.textContent = [
".sharelinks-overlay{position:fixed;inset:0;z-index:999999;background:rgba(0,0,0,.58);display:grid;place-items:center;padding:24px;opacity:0;transition:opacity .16s ease;}",
".sharelinks-overlay.is-visible{opacity:1;}",
".sharelinks-dialog{width:min(520px,100%);background:var(--background-color,#202020);color:var(--text-color,#fff);box-shadow:0 18px 60px rgba(0,0,0,.45);border:1px solid rgba(255,255,255,.12);border-radius:8px;padding:22px;}",
".sharelinks-title{font-size:1.25rem;line-height:1.3;margin:0 0 14px;font-weight:600;}",
".sharelinks-note{margin:0 0 16px;color:var(--text-secondary-color,#cfcfcf);line-height:1.45;}",
".sharelinks-duration-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:4px;}",
".sharelinks-duration-button,.sharelinks-action{border:0;border-radius:6px;background:rgba(255,255,255,.12);color:inherit;padding:10px 12px;min-height:42px;cursor:pointer;font:inherit;}",
".sharelinks-duration-button:hover:not(:disabled),.sharelinks-action:hover{background:rgba(255,255,255,.18);}",
".sharelinks-duration-button:disabled{opacity:.35;cursor:not-allowed;}",
".sharelinks-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:18px;}",
".sharelinks-action.primary{background:var(--theme-primary-color,#00a4dc);color:#fff;}",
".sharelinks-url{width:100%;min-height:88px;box-sizing:border-box;border:1px solid rgba(255,255,255,.2);border-radius:6px;background:rgba(0,0,0,.18);color:inherit;padding:10px;font:inherit;resize:vertical;}",
".sharelinks-date-row{margin-top:18px;display:flex;flex-direction:column;gap:8px;}",
".sharelinks-date-label{color:var(--text-secondary-color,#cfcfcf);font-size:.92rem;line-height:1.4;}",
".sharelinks-date-input{width:100%;box-sizing:border-box;border:1px solid rgba(255,255,255,.2);border-radius:6px;background:rgba(0,0,0,.18);color:inherit;padding:10px 12px;min-height:42px;font:inherit;color-scheme:dark;}",
".sharelinks-toggle-row{display:flex;align-items:flex-start;gap:10px;margin-top:18px;padding-top:16px;border-top:1px solid rgba(255,255,255,.12);cursor:pointer;}",
".sharelinks-toggle-input{margin:2px 0 0;width:18px;height:18px;flex:0 0 auto;accent-color:var(--theme-primary-color,#00a4dc);cursor:pointer;}",
".sharelinks-toggle-text{display:flex;flex-direction:column;gap:4px;}",
".sharelinks-toggle-label{line-height:1.35;}",
".sharelinks-toggle-hint{color:var(--text-secondary-color,#cfcfcf);font-size:.88rem;line-height:1.4;}",
".sharelinks-toast{position:fixed;left:24px;bottom:24px;z-index:1000000;max-width:min(460px,calc(100vw - 48px));background:rgba(24,24,24,.96);color:#fff;border:1px solid rgba(255,255,255,.14);border-radius:6px;padding:11px 14px;box-shadow:0 10px 30px rgba(0,0,0,.35);opacity:0;transform:translateY(8px);transition:opacity .18s ease,transform .18s ease;}",
".sharelinks-toast.is-visible{opacity:1;transform:translateY(0);}",
"@media (max-width:520px){.sharelinks-duration-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.sharelinks-dialog{padding:18px;}.sharelinks-actions{justify-content:stretch;}.sharelinks-action{flex:1;}}",
].join("");
document.head.appendChild(style);
}
function fallbackCopy(text) {
var textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "readonly");
textarea.style.position = "fixed";
textarea.style.top = "-1000px";
textarea.style.left = "-1000px";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
var copied = false;
try {
copied = document.execCommand("copy");
} catch (error) {
copied = false;
}
textarea.remove();
return copied;
}
function clampPositiveInteger(value, fallback) {
var parsed = parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function extractErrorMessage(error, fallback) {
if (!error) {
return fallback;
}
if (typeof error === "string") {
return error;
}
if (error.responseJSON && error.responseJSON.error) {
return error.responseJSON.error;
}
if (error.responseText) {
return error.responseText;
}
if (error.message) {
return error.message;
}
return fallback;
}
start();
})();