()
};
diff --git a/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs
index 1f60e29..576f15a 100644
--- a/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs
+++ b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs
@@ -117,7 +117,11 @@ public sealed class ShareLinkRedemptionService
record.MetadataTouched = true;
}
- if (string.IsNullOrWhiteSpace(record.DeviceId))
+ // Jellyfin logs out any existing session for the same user and device id,
+ // so every viewer of a multi-use link needs a device id of their own or
+ // each new arrival would kick the previous one off. A one-use link has a
+ // single viewer and keeps a stable id.
+ if (!record.OneUse || string.IsNullOrWhiteSpace(record.DeviceId))
{
record.DeviceId = Guid.NewGuid().ToString("N");
}
diff --git a/Jellyfin.Plugin.ShareLinks/Web/configPage.html b/Jellyfin.Plugin.ShareLinks/Web/configPage.html
index 2b5a11d..8581f6f 100644
--- a/Jellyfin.Plugin.ShareLinks/Web/configPage.html
+++ b/Jellyfin.Plugin.ShareLinks/Web/configPage.html
@@ -101,8 +101,9 @@
diff --git a/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js b/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js
index c86b4e3..68720b9 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.2-ui-3';
+ var clientVersion = '1.0.3-ui-1';
var allowedItemStorageKey = 'sharelinks.allowedItemId';
var guestClassName = 'sharelinks-guest';
var hiddenAttr = 'data-sharelinks-hidden';
@@ -65,6 +65,9 @@
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.',
+ 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.',
@@ -89,6 +92,9 @@
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.',
+ 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é.',
@@ -1063,11 +1069,11 @@
return;
}
- var result = await chooseExpiryHours(config, function (expiryHours) {
+ var result = await chooseExpiryHours(config, function (expiryHours, multiUse) {
var payload = {
itemId: itemId,
expiryHours: expiryHours,
- oneUse: config && config.OneUseDefault !== undefined ? !!config.OneUseDefault : true
+ oneUse: !multiUse
};
var shareUrlPromise = apiPost('ShareLinks/Admin/Create', payload).then(function (response) {
@@ -1086,7 +1092,8 @@
}).then(function (copied) {
return {
shareUrl: shareUrl,
- copied: copied
+ copied: copied,
+ multiUse: !!multiUse
};
});
});
@@ -1095,7 +1102,7 @@
return;
}
- showShareResult(result.shareUrl, result.copied);
+ showShareResult(result.shareUrl, result.copied, result.multiUse);
} catch (error) {
notify(extractErrorMessage(error, t('couldNotCreate')));
}
@@ -1134,6 +1141,11 @@
options: options,
onChoose: onChoose,
cancelText: t('cancel'),
+ toggle: {
+ label: t('multiUseLabel'),
+ hint: t('multiUseHint'),
+ checked: !(config && config.OneUseDefault !== false)
+ },
datePicker: {
min: toLocalDatetimeValue(minDate),
max: toLocalDatetimeValue(maxDate),
@@ -1193,7 +1205,7 @@
}, 3600);
}
- function showShareResult(shareUrl, copied) {
+ function showShareResult(shareUrl, copied, multiUse) {
ensureShareLinksUi();
var body = document.createElement('div');
var note = document.createElement('p');
@@ -1203,6 +1215,13 @@
: 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;
@@ -1276,6 +1295,40 @@
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 = [];
@@ -1310,7 +1363,7 @@
if (modal) {
modal.close();
}
- resolve(settings.onChoose ? settings.onChoose(hours) : hours);
+ resolve(settings.onChoose ? settings.onChoose(hours, toggleChecked()) : hours);
}
});
}
@@ -1341,7 +1394,7 @@
button.addEventListener('click', function () {
modal.close();
if (settings.onChoose) {
- resolve(settings.onChoose(option.hours));
+ resolve(settings.onChoose(option.hours, toggleChecked()));
} else {
resolve(option.hours);
}
@@ -1459,6 +1512,11 @@
'.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;}}'
diff --git a/Jellyfin.Plugin.ShareLinks/meta.json b/Jellyfin.Plugin.ShareLinks/meta.json
index c931d98..fa65dc0 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.2.0",
+ "version": "1.0.3.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-26T00:00:00.0000000Z"
+ "timestamp": "2026-07-26T19:30:00.0000000Z"
}
diff --git a/README.md b/README.md
index 8d70d41..0525997 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,10 @@ real user or handing over a login that sees everything.
1. As an admin you open the context menu on a movie, episode, series or season
and hit **ShareLink**. You choose an expiry (1 hour up to 30 days) and the
- plugin hands you a link, copied to your clipboard.
+ plugin hands you a link, copied to your clipboard. You also choose there
+ whether the link is single use, which is the default and stops working once
+ the first person opens it, or multi-use, which lets everyone you send it to
+ open it until it expires.
2. Behind the scenes the plugin tags the shared item with a unique, random tag
and records the share. Share a series or a season and the tag is applied to
the whole tree underneath it too - series, seasons and episodes - so the
@@ -125,7 +128,7 @@ All of these live on the plugin's dashboard page:
| Guest username prefix | Prefix for the throwaway guest accounts (default `share-`) |
| Allow transcoding / remuxing | Whether guest playback may transcode or remux |
| Cleanup interval | How often the background cleanup runs |
-| One-use default | Whether new links default to single redemption |
+| Single use by default | How the single-use box starts out in the create popup; it is a per-link choice |
| Guest lockdown | The web-client confinement described above (on by default) |
| Guest hidden selectors | CSS selectors hidden from guests, to suppress other plugins' UI |