let a link be used by several people instead of dying on first open

The create popup now has a "Let several people use this link" box next to the
expiry controls. Tick it and the link stays redeemable by everyone you send it to
until it expires; leave it and you get the old behaviour, where the first person
to open it is the only one who gets in. The plugin setting that used to be the
only control is now just what the box starts out as, and its label on the config
page says so, because "Default one-use links" explained nothing.

Multi-use did not actually work before this. Two things in Jellyfin stopped it,
and both had to change:

Guests were given MaxActiveSessions = 1, and AuthenticateNewSessionInternal throws
SecurityException once a user is at that limit. The second viewer's redemption
would fail, the record would go to Failed, and cleanup would then delete the guest
account, kicking the first viewer out too. Multi-use links now get 0, which is how
Jellyfin spells "no limit" in that check.

The device id was generated once and reused for every redemption, and
GetAuthorizationToken logs out every existing session for the same user and device
before issuing a token. So even under a raised session cap, each new viewer would
have revoked the previous one's token. Multi-use links now mint a device id per
redemption.

Both viewers of a multi-use link share one temporary account, so they also share
playback position and watched state on the shared title.
Cette révision appartient à :
Franciskid
2026-07-26 21:05:54 +02:00
Parent ca0ab96ee9
révision f0b9e8351b
7 fichiers modifiés avec 86 ajouts et 18 suppressions
+3 -3
Voir le fichier
@@ -6,9 +6,9 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<RootNamespace>Jellyfin.Plugin.ShareLinks</RootNamespace> <RootNamespace>Jellyfin.Plugin.ShareLinks</RootNamespace>
<AssemblyName>Jellyfin.Plugin.ShareLinks</AssemblyName> <AssemblyName>Jellyfin.Plugin.ShareLinks</AssemblyName>
<Version>1.0.2.0</Version> <Version>1.0.3.0</Version>
<AssemblyVersion>1.0.2.0</AssemblyVersion> <AssemblyVersion>1.0.3.0</AssemblyVersion>
<FileVersion>1.0.2.0</FileVersion> <FileVersion>1.0.3.0</FileVersion>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo> <GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors> <TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<ImplicitUsings>disable</ImplicitUsings> <ImplicitUsings>disable</ImplicitUsings>
+3 -1
Voir le fichier
@@ -187,7 +187,9 @@ public sealed class JellyfinGuestUserService
EnabledFolders = Array.Empty<Guid>(), EnabledFolders = Array.Empty<Guid>(),
EnablePublicSharing = false, EnablePublicSharing = false,
LoginAttemptsBeforeLockout = -1, LoginAttemptsBeforeLockout = -1,
MaxActiveSessions = 1, // One viewer for a one-use link. A multi-use link needs a session per
// viewer, and 0 is how Jellyfin spells "no limit" in its session check.
MaxActiveSessions = record.OneUse ? 1 : 0,
BlockUnratedItems = Array.Empty<UnratedItem>() BlockUnratedItems = Array.Empty<UnratedItem>()
}; };
+5 -1
Voir le fichier
@@ -117,7 +117,11 @@ public sealed class ShareLinkRedemptionService
record.MetadataTouched = true; 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"); record.DeviceId = Guid.NewGuid().ToString("N");
} }
+2 -1
Voir le fichier
@@ -101,8 +101,9 @@
<div class="checkboxContainer checkboxContainer-withDescription"> <div class="checkboxContainer checkboxContainer-withDescription">
<label> <label>
<input is="emby-checkbox" type="checkbox" id="OneUseDefault" /> <input is="emby-checkbox" type="checkbox" id="OneUseDefault" />
<span>Default one-use links</span> <span>New links are single use by default</span>
</label> </label>
<div class="fieldDescription checkboxFieldDescription">This only decides how the "Let several people use this link" box starts out in the create popup; you can change it for every link you make. A single-use link stops working the moment the first person opens it, and only that person keeps access until it expires. A multi-use link can be opened by everyone you send it to, for as long as it is valid.</div>
</div> </div>
<div class="sl-field inputContainer"> <div class="sl-field inputContainer">
+66 -8
Voir le fichier
@@ -2,7 +2,7 @@
var pluginId = '68540b76-ee74-436d-85ff-2abc884bbea6'; var pluginId = '68540b76-ee74-436d-85ff-2abc884bbea6';
var copyLabel = 'Copy Stream URL'; var copyLabel = 'Copy Stream URL';
var actionLabel = 'ShareLink'; var actionLabel = 'ShareLink';
var clientVersion = '1.0.2-ui-3'; var clientVersion = '1.0.3-ui-1';
var allowedItemStorageKey = 'sharelinks.allowedItemId'; var allowedItemStorageKey = 'sharelinks.allowedItemId';
var guestClassName = 'sharelinks-guest'; var guestClassName = 'sharelinks-guest';
var hiddenAttr = 'data-sharelinks-hidden'; var hiddenAttr = 'data-sharelinks-hidden';
@@ -65,6 +65,9 @@
pickDateFirst: 'Pick a date and time first.', pickDateFirst: 'Pick a date and time first.',
dateInvalid: 'That date is not valid.', dateInvalid: 'That date is not valid.',
pickFuture: 'Pick a time in the future.', 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.', cannotDetermineItem: 'Could not determine which item to share. Open the item page and retry.',
adminOnly: 'ShareLinks is available to administrators only.', adminOnly: 'ShareLinks is available to administrators only.',
disabled: 'ShareLinks is disabled.', disabled: 'ShareLinks is disabled.',
@@ -89,6 +92,9 @@
pickDateFirst: 'Choisissez d\'abord une date et une heure.', pickDateFirst: 'Choisissez d\'abord une date et une heure.',
dateInvalid: 'Cette date n\'est pas valide.', dateInvalid: 'Cette date n\'est pas valide.',
pickFuture: 'Choisissez une date dans le futur.', 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.', 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.', adminOnly: 'ShareLinks est réservé aux administrateurs.',
disabled: 'ShareLinks est désactivé.', disabled: 'ShareLinks est désactivé.',
@@ -1063,11 +1069,11 @@
return; return;
} }
var result = await chooseExpiryHours(config, function (expiryHours) { var result = await chooseExpiryHours(config, function (expiryHours, multiUse) {
var payload = { var payload = {
itemId: itemId, itemId: itemId,
expiryHours: expiryHours, expiryHours: expiryHours,
oneUse: config && config.OneUseDefault !== undefined ? !!config.OneUseDefault : true oneUse: !multiUse
}; };
var shareUrlPromise = apiPost('ShareLinks/Admin/Create', payload).then(function (response) { var shareUrlPromise = apiPost('ShareLinks/Admin/Create', payload).then(function (response) {
@@ -1086,7 +1092,8 @@
}).then(function (copied) { }).then(function (copied) {
return { return {
shareUrl: shareUrl, shareUrl: shareUrl,
copied: copied copied: copied,
multiUse: !!multiUse
}; };
}); });
}); });
@@ -1095,7 +1102,7 @@
return; return;
} }
showShareResult(result.shareUrl, result.copied); showShareResult(result.shareUrl, result.copied, result.multiUse);
} catch (error) { } catch (error) {
notify(extractErrorMessage(error, t('couldNotCreate'))); notify(extractErrorMessage(error, t('couldNotCreate')));
} }
@@ -1134,6 +1141,11 @@
options: options, options: options,
onChoose: onChoose, onChoose: onChoose,
cancelText: t('cancel'), cancelText: t('cancel'),
toggle: {
label: t('multiUseLabel'),
hint: t('multiUseHint'),
checked: !(config && config.OneUseDefault !== false)
},
datePicker: { datePicker: {
min: toLocalDatetimeValue(minDate), min: toLocalDatetimeValue(minDate),
max: toLocalDatetimeValue(maxDate), max: toLocalDatetimeValue(maxDate),
@@ -1193,7 +1205,7 @@
}, 3600); }, 3600);
} }
function showShareResult(shareUrl, copied) { function showShareResult(shareUrl, copied, multiUse) {
ensureShareLinksUi(); ensureShareLinksUi();
var body = document.createElement('div'); var body = document.createElement('div');
var note = document.createElement('p'); var note = document.createElement('p');
@@ -1203,6 +1215,13 @@
: t('notCopiedNote'); : t('notCopiedNote');
body.appendChild(note); 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'); var urlBox = document.createElement('textarea');
urlBox.className = 'sharelinks-url'; urlBox.className = 'sharelinks-url';
urlBox.readOnly = true; urlBox.readOnly = true;
@@ -1276,6 +1295,40 @@
body.appendChild(dateRow); 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) { return new Promise(function (resolve) {
var modal; var modal;
var actions = []; var actions = [];
@@ -1310,7 +1363,7 @@
if (modal) { if (modal) {
modal.close(); 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 () { button.addEventListener('click', function () {
modal.close(); modal.close();
if (settings.onChoose) { if (settings.onChoose) {
resolve(settings.onChoose(option.hours)); resolve(settings.onChoose(option.hours, toggleChecked()));
} else { } else {
resolve(option.hours); resolve(option.hours);
} }
@@ -1459,6 +1512,11 @@
'.sharelinks-date-row{margin-top:18px;display:flex;flex-direction:column;gap:8px;}', '.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-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-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{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);}', '.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;}}' '@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;}}'
+2 -2
Voir le fichier
@@ -1,12 +1,12 @@
{ {
"guid": "68540b76-ee74-436d-85ff-2abc884bbea6", "guid": "68540b76-ee74-436d-85ff-2abc884bbea6",
"name": "ShareLinks", "name": "ShareLinks",
"version": "1.0.2.0", "version": "1.0.3.0",
"targetAbi": "10.11.0.0", "targetAbi": "10.11.0.0",
"framework": "net9.0", "framework": "net9.0",
"owner": "Franciskid", "owner": "Franciskid",
"overview": "Secure expiring guest-share links for Jellyfin items.", "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.", "description": "Adds secure, expiring share links for Jellyfin items with JSON-backed storage, token hashing, and cleanup scaffolding.",
"category": "General", "category": "General",
"timestamp": "2026-07-26T00:00:00.0000000Z" "timestamp": "2026-07-26T19:30:00.0000000Z"
} }
+5 -2
Voir le fichier
@@ -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 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 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 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 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 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-`) | | Guest username prefix | Prefix for the throwaway guest accounts (default `share-`) |
| Allow transcoding / remuxing | Whether guest playback may transcode or remux | | Allow transcoding / remuxing | Whether guest playback may transcode or remux |
| Cleanup interval | How often the background cleanup runs | | 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 lockdown | The web-client confinement described above (on by default) |
| Guest hidden selectors | CSS selectors hidden from guests, to suppress other plugins' UI | | Guest hidden selectors | CSS selectors hidden from guests, to suppress other plugins' UI |