diff --git a/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs b/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs
index 213ceaf..beda0a6 100644
--- a/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs
+++ b/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs
@@ -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;
}
+ ///
+ /// 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.
+ ///
+ 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;
diff --git a/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj b/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj
index 23d193d..a717594 100644
--- a/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj
+++ b/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj
@@ -6,9 +6,9 @@
latest
Jellyfin.Plugin.ShareLinks
Jellyfin.Plugin.ShareLinks
- 1.0.1.0
- 1.0.1.0
- 1.0.1.0
+ 1.0.2.0
+ 1.0.2.0
+ 1.0.2.0
true
false
disable
diff --git a/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js b/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js
index d7c1b6d..ab60134 100644
--- a/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js
+++ b/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js
@@ -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() {
diff --git a/Jellyfin.Plugin.ShareLinks/meta.json b/Jellyfin.Plugin.ShareLinks/meta.json
index 423911a..c931d98 100644
--- a/Jellyfin.Plugin.ShareLinks/meta.json
+++ b/Jellyfin.Plugin.ShareLinks/meta.json
@@ -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"
}
diff --git a/README.md b/README.md
index 463ab4c..b0344ef 100644
--- a/README.md
+++ b/README.md
@@ -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