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 à :
@@ -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>
|
||||||
|
|||||||
@@ -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>()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;}}'
|
||||||
|
|||||||
@@ -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
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
Référencer dans un nouveau ticket
Bloquer un utilisateur