move the ShareLink action to its own section at the bottom of the item menu

The button used to be cloned in next to "Copy Stream URL", or shoved at the top
of the menu when there was none, which put it in the middle of Jellyfin's own
command groups. Now there is a single injection path: append an actionsheetDivider
plus the action at the end of the sheet's scroller, so it reads as its own
section the way Jellyfin separates its groups.

It also stopped appearing on menus for things that are not media. The old code
resolved the item from the URL, so opening the "..." menu on a cast member inside
a movie page showed ShareLink and would have shared the movie behind it. The item
is now taken from the trigger that opened the menu (card, list row or the detail
page's own button, all of which we can walk up from) and its type is checked
against the server before anything is injected: movie, series, season, episode,
nothing else. The API refuses everything else too, so a hand-rolled request
cannot tag a person or a playlist either.

Clicking the action now dismisses the action sheet instead of leaving it stacked
under the dialog.
Cette révision appartient à :
Franciskid
2026-07-26 16:21:05 +02:00
Parent 36fc574d49
révision 35631eae46
5 fichiers modifiés avec 253 ajouts et 123 suppressions
+19 -3
Voir le fichier
@@ -12,6 +12,7 @@ using Jellyfin.Plugin.ShareLinks.Models;
using Jellyfin.Plugin.ShareLinks.Services;
using Jellyfin.Plugin.ShareLinks.Storage;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
@@ -202,10 +203,14 @@ public sealed class ShareLinksController : ControllerBase
return NotFound(new { error = "Item not found." });
}
if (item.IsFolder && item is not Series && item is not Season)
if (!IsShareableItem(item))
{
_logger.LogWarning("ShareLinks: create rejected, item {ItemId} \"{ItemName}\" is a folder or library, not shareable media.", itemId, item.Name);
return BadRequest(new { error = "Only a movie, episode, series or season can be shared, not a library or collection. Open the title's page and try again." });
_logger.LogWarning(
"ShareLinks: create rejected, item {ItemId} \"{ItemName}\" is a {ItemType}, which is not shareable media.",
itemId,
item.Name,
item.GetType().Name);
return BadRequest(new { error = "Only a movie, series, season or episode can be shared. Open the title's page and try again." });
}
try
@@ -412,6 +417,17 @@ setTimeout(function () { window.location.replace({{redirectUrlJson}}); }, 4000);
?? User.Identity?.Name;
}
/// <summary>
/// Only real video titles are shareable. Anything else - a person, studio,
/// genre, library, collection, playlist, music track or book - would either
/// give the guest nothing to play or pull unrelated items into the guest's tag
/// policy, so the API refuses it even when a client asks for it.
/// </summary>
private static bool IsShareableItem(BaseItem item)
{
return item is Movie or Series or Season or Episode;
}
private static bool IsExpired(ShareLinkRecord record)
{
return record.ExpiresAtUtc <= DateTimeOffset.UtcNow;
+3 -3
Voir le fichier
@@ -6,9 +6,9 @@
<LangVersion>latest</LangVersion>
<RootNamespace>Jellyfin.Plugin.ShareLinks</RootNamespace>
<AssemblyName>Jellyfin.Plugin.ShareLinks</AssemblyName>
<Version>1.0.1.0</Version>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.0.1.0</FileVersion>
<Version>1.0.2.0</Version>
<AssemblyVersion>1.0.2.0</AssemblyVersion>
<FileVersion>1.0.2.0</FileVersion>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<ImplicitUsings>disable</ImplicitUsings>
+224 -112
Voir le fichier
@@ -2,7 +2,7 @@
var pluginId = '68540b76-ee74-436d-85ff-2abc884bbea6';
var copyLabel = 'Copy Stream URL';
var actionLabel = 'ShareLink';
var clientVersion = '1.0.1-ui-2';
var clientVersion = '1.0.2-ui-1';
var allowedItemStorageKey = 'sharelinks.allowedItemId';
var guestClassName = 'sharelinks-guest';
var hiddenAttr = 'data-sharelinks-hidden';
@@ -14,15 +14,22 @@
var scanQueued = false;
var bootRetry = null;
var observer = null;
var lastContextItemId = null;
var lastContextItemTs = 0;
var menuContext = null;
var shareableItemTypes = ['movie', 'series', 'season', 'episode'];
var menuTriggerSelector = '[data-action="menu"], .btnMoreCommands, .btnCardOptions, .cardOverlayButton';
var itemTypeCache = {};
var itemTypeInFlight = {};
var historyPatched = false;
var itemMenuActionIds = [
'moreinfo', 'mediainfo', 'editmetadata', 'editimages', 'editsubtitles',
'editlyrics', 'identify', 'refreshmetadata', 'refresh', 'playlist',
'addtoplaylist', 'addtocollection', 'instantmix', 'shuffle', 'resume',
'copy-stream', 'copystream', 'share', 'download', 'delete',
'edit'
'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 },
@@ -142,16 +149,7 @@
window.addEventListener('popstate', scheduleWork, true);
document.addEventListener('pointerdown', function (event) {
var node = event.target;
while (node && node !== document) {
var id = readItemIdFromNode(node);
if (id) {
lastContextItemId = id;
lastContextItemTs = Date.now();
return;
}
node = node.parentElement;
}
rememberMenuContext(event.target);
}, true);
observer = new MutationObserver(scheduleWork);
@@ -441,17 +439,123 @@
return;
}
var copyNodes = [];
Array.from(document.querySelectorAll('button, a, [role="menuitem"], [role="option"], .actionsheetMenuItem, .paperListButton')).forEach(function (node) {
if (isCopyStreamUrlLabel(getVisibleLabel(node))) {
copyNodes.push(node);
insertActionAfter(node);
}
});
if (copyNodes.length === 0) {
insertIntoOpenMenuFallback();
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);
}
/**
* 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();
});
}
function isCopyStreamUrlLabel(label) {
@@ -465,25 +569,6 @@
|| (value.indexOf('copier') >= 0 && value.indexOf('url') >= 0 && (value.indexOf('flux') >= 0 || value.indexOf('stream') >= 0));
}
function insertIntoOpenMenuFallback() {
var itemId = resolveItemId(document);
if (!itemId) {
return;
}
var container = findOpenActionContainer();
if (!container || container.querySelector('[' + injectedAttr + '="1"]')) {
return;
}
var template = findBestActionTemplate(container);
if (!template) {
return;
}
insertActionAfter(template, itemId, true);
}
function isItemMenuAction(node) {
if (!node || !node.getAttribute) {
return false;
@@ -535,12 +620,10 @@
function findBestActionTemplate(container) {
var actions = Array.from(container.querySelectorAll('.actionSheetMenuItem, .actionsheetMenuItem'))
.filter(isVisible);
for (var i = 0; i < actions.length; i += 1) {
if (isCopyStreamUrlLabel(getVisibleLabel(actions[i]))) {
return actions[i];
}
}
.filter(isVisible)
.filter(function (node) {
return node.getAttribute(injectedAttr) !== '1';
});
return actions.length ? actions[0] : null;
}
@@ -549,88 +632,117 @@
return !!(node && (node.offsetWidth || node.offsetHeight || node.getClientRects().length));
}
function insertActionAfter(copyNode, explicitItemId, insertAtTop) {
var parent = copyNode.parentElement;
if (!parent || parent.querySelector('[' + injectedAttr + '="1"]')) {
/**
* 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 itemId = explicitItemId || resolveItemId(copyNode) || resolveItemId(document);
if (!itemId) {
var scroller = container.querySelector('.actionSheetScroller') || template.parentElement;
if (!scroller) {
return;
}
var sourceLabel = getVisibleLabel(copyNode);
var injected = copyNode.cloneNode(true);
injected.setAttribute(injectedAttr, '1');
injected.setAttribute('type', 'button');
injected.removeAttribute('id');
injected.removeAttribute('href');
injected.removeAttribute('onclick');
injected.removeAttribute('target');
injected.removeAttribute('download');
// The clone inherits the template's data-id (e.g. 'moreinfo'), which would duplicate
// an existing menu item's id and could shadow/trigger its command; strip it.
injected.removeAttribute('data-id');
injected.setAttribute('aria-label', actionLabel);
injected.setAttribute('title', actionLabel);
injected.dataset.sharelinksItemId = itemId;
var divider = document.createElement('div');
divider.className = 'actionsheetDivider';
divider.setAttribute(injectedAttr, '1');
var injectedIcon = injected.querySelector('.material-icons');
if (injectedIcon) {
injectedIcon.classList.remove('content_copy');
injectedIcon.classList.add('share');
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;
}
injected.addEventListener('click', function (event) {
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);
if (!replaceText(injected, sourceLabel, actionLabel)) {
injected.textContent = actionLabel;
}
if (insertAtTop) {
copyNode.insertAdjacentElement('beforebegin', injected);
} else {
copyNode.insertAdjacentElement('afterend', injected);
}
return action;
}
function replaceText(root, from, to) {
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
while (walker.nextNode()) {
var node = walker.currentNode;
if (normalizeText(node.nodeValue) === from) {
node.nodeValue = node.nodeValue.replace(from, to);
return true;
}
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;
}
return false;
action.textContent = actionLabel;
}
function resolveItemId(node) {
var direct = parseItemIdFromUrl();
if (direct) {
return direct;
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);
}
var current = node;
while (current && current !== document) {
var id = readItemIdFromNode(current);
if (id) {
return id;
}
current = current.parentElement;
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;
}
if (lastContextItemId && (Date.now() - lastContextItemTs) < 8000) {
return lastContextItemId;
}
return findItemIdInDocument();
container.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape',
keyCode: 27,
which: 27,
bubbles: true,
cancelable: true
}));
}
function parseItemIdFromUrl() {
+2 -2
Voir le fichier
@@ -1,12 +1,12 @@
{
"guid": "68540b76-ee74-436d-85ff-2abc884bbea6",
"name": "ShareLinks",
"version": "1.0.1.0",
"version": "1.0.2.0",
"targetAbi": "10.11.0.0",
"framework": "net9.0",
"owner": "Franciskid",
"overview": "Secure expiring guest-share links for Jellyfin items.",
"description": "Adds secure, expiring share links for Jellyfin items with JSON-backed storage, token hashing, and cleanup scaffolding.",
"category": "General",
"timestamp": "2026-07-08T00:00:00.0000000Z"
"timestamp": "2026-07-26T00:00:00.0000000Z"
}
+5 -3
Voir le fichier
@@ -6,9 +6,11 @@ them an account, without them seeing the rest of your library.
> *"here, watch this one film, the link dies tomorrow"*
ShareLinks adds a **ShareLink** button (with a little share icon) to the context
menu of any movie, episode, series or season in the Jellyfin web client. Click
it, pick how long the link should live, and you get a URL you can send to
anyone. When they open it, they land straight on that title, already signed in,
menu of any movie, episode, series or season in the Jellyfin web client. It sits
in its own section at the bottom of the menu, and it only shows up for those four
kinds of item, never for a cast member, a studio, a library or anything else you
cannot actually watch. Click it, pick how long the link should live, and you get
a URL you can send to anyone. When they open it, they land straight on that title, already signed in,
and they cannot wander off into the rest of your server.
No account for them to create, no password for you to hand out, no permanent