Merge pull request #10 from Franciskid/feature/multi-use-links

Let a link be used by several people instead of dying on first open
Cette révision appartient à :
Francois CB
2026-07-26 21:29:25 +02:00
révisé par GitHub
révision 111bede362
9 fichiers modifiés avec 227 ajouts et 33 suppressions
+47 -3
Voir le fichier
@@ -326,13 +326,18 @@ public sealed class ShareLinksController : ControllerBase
return LinkUnavailablePage(Request); return LinkUnavailablePage(Request);
} }
var html = await _redemptionService.RedeemAsync(token, Request, cancellationToken).ConfigureAwait(false); var result = await _redemptionService.RedeemAsync(token, Request, cancellationToken).ConfigureAwait(false);
if (html is null) if (result.AtCapacity)
{
return LinkBusyPage();
}
if (result.Html is null)
{ {
return LinkUnavailablePage(Request); return LinkUnavailablePage(Request);
} }
return Content(html, "text/html; charset=utf-8"); return Content(result.Html, "text/html; charset=utf-8");
} }
private static ContentResult LinkUnavailablePage(HttpRequest request) private static ContentResult LinkUnavailablePage(HttpRequest request)
@@ -380,6 +385,45 @@ setTimeout(function () { window.location.replace({{redirectUrlJson}}); }, 4000);
}; };
} }
/// <summary>
/// Served when a multi-use link has as many viewers as it is allowed. The link
/// itself is still good, so this deliberately invites a retry instead of
/// looking like a dead link.
/// </summary>
private static ContentResult LinkBusyPage()
{
var html = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Too many viewers</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; background: #111827; color: #e5e7eb; }
main { max-width: 36rem; padding: 2rem; }
.muted { color: #9ca3af; }
a { color: #60a5fa; }
</style>
</head>
<body>
<main>
<div>This link is being watched by as many people as it allows right now.</div>
<div class="muted">Ce lien est deja utilise par autant de personnes qu'il l'autorise.</div>
<p><a href="">Try again</a></p>
</main>
</body>
</html>
""";
return new ContentResult
{
StatusCode = StatusCodes.Status503ServiceUnavailable,
ContentType = "text/html; charset=utf-8",
Content = html
};
}
private static ShareLinkAdminRecordDto ToDto(ShareLinkRecord record) private static ShareLinkAdminRecordDto ToDto(ShareLinkRecord record)
{ {
return new ShareLinkAdminRecordDto return new ShareLinkAdminRecordDto
+6
Voir le fichier
@@ -39,6 +39,12 @@ public class PluginConfiguration : BasePluginConfiguration
/// <summary>Gets or sets a value indicating whether links default to one use.</summary> /// <summary>Gets or sets a value indicating whether links default to one use.</summary>
public bool OneUseDefault { get; set; } = true; public bool OneUseDefault { get; set; } = true;
/// <summary>
/// Gets or sets how many people may watch a multi-use link at the same time.
/// 0 means no limit. One-use links are always a single viewer regardless.
/// </summary>
public int MaxConcurrentViewers { get; set; } = 10;
/// <summary>Gets or sets a value indicating whether guest-mode lockdown is enabled.</summary> /// <summary>Gets or sets a value indicating whether guest-mode lockdown is enabled.</summary>
public bool GuestModeLockdownEnabled { get; set; } = true; public bool GuestModeLockdownEnabled { get; set; } = true;
+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 gets the configured
// ceiling, where 0 is how Jellyfin spells "no limit" in its session check.
MaxActiveSessions = record.OneUse ? 1 : Math.Max(config.MaxConcurrentViewers, 0),
BlockUnratedItems = Array.Empty<UnratedItem>() BlockUnratedItems = Array.Empty<UnratedItem>()
}; };
+42 -13
Voir le fichier
@@ -1,4 +1,5 @@
using System; using System;
using System.Security;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -13,6 +14,19 @@ using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.ShareLinks.Services; namespace Jellyfin.Plugin.ShareLinks.Services;
/// <summary>Outcome of a redemption attempt.</summary>
public sealed class ShareLinkRedemptionResult
{
/// <summary>Gets the bootstrap HTML when a session was minted, otherwise null.</summary>
public string? Html { get; init; }
/// <summary>
/// Gets a value indicating whether the link is valid but already has as many
/// viewers as it is allowed to have.
/// </summary>
public bool AtCapacity { get; init; }
}
/// <summary>Handles public share-link redemption and the bootstrap HTML response.</summary> /// <summary>Handles public share-link redemption and the bootstrap HTML response.</summary>
public sealed class ShareLinkRedemptionService public sealed class ShareLinkRedemptionService
{ {
@@ -47,8 +61,8 @@ public sealed class ShareLinkRedemptionService
_logger = logger; _logger = logger;
} }
/// <summary>Redeems a token and returns the bootstrap HTML, or null if the token is unusable.</summary> /// <summary>Redeems a token and returns the redemption result.</summary>
public async Task<string?> RedeemAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken) public async Task<ShareLinkRedemptionResult> RedeemAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken)
{ {
// One redemption at a time: the status checks below and the status write // One redemption at a time: the status checks below and the status write
// that follows them are not atomic, so two requests arriving together with // that follows them are not atomic, so two requests arriving together with
@@ -65,50 +79,50 @@ public sealed class ShareLinkRedemptionService
} }
/// <summary>Runs a single redemption; callers must hold the redemption gate.</summary> /// <summary>Runs a single redemption; callers must hold the redemption gate.</summary>
private async Task<string?> RedeemInternalAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken) private async Task<ShareLinkRedemptionResult> RedeemInternalAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken)
{ {
var tokenHash = await _tokenService.HashTokenAsync(rawToken, cancellationToken).ConfigureAwait(false); var tokenHash = await _tokenService.HashTokenAsync(rawToken, cancellationToken).ConfigureAwait(false);
if (tokenHash is null) if (tokenHash is null)
{ {
return null; return new ShareLinkRedemptionResult();
} }
var record = await _store.GetByTokenHashAsync(tokenHash, cancellationToken).ConfigureAwait(false); var record = await _store.GetByTokenHashAsync(tokenHash, cancellationToken).ConfigureAwait(false);
if (record is null) if (record is null)
{ {
return null; return new ShareLinkRedemptionResult();
} }
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
if (record.ExpiresAtUtc <= now) if (record.ExpiresAtUtc <= now)
{ {
await HandleTerminalRecordAsync(record, ShareLinkStatus.Expired, "Share link has expired.", cancellationToken).ConfigureAwait(false); await HandleTerminalRecordAsync(record, ShareLinkStatus.Expired, "Share link has expired.", cancellationToken).ConfigureAwait(false);
return null; return new ShareLinkRedemptionResult();
} }
if (record.Status == ShareLinkStatus.Revoked || record.Status == ShareLinkStatus.Failed) if (record.Status == ShareLinkStatus.Revoked || record.Status == ShareLinkStatus.Failed)
{ {
return null; return new ShareLinkRedemptionResult();
} }
// Checked before any library write: re-tagging the whole tree on every hit // Checked before any library write: re-tagging the whole tree on every hit
// to an already-spent link would be a pointless metadata write storm. // to an already-spent link would be a pointless metadata write storm.
if (record.OneUse && record.Status == ShareLinkStatus.Redeemed) if (record.OneUse && record.Status == ShareLinkStatus.Redeemed)
{ {
return null; return new ShareLinkRedemptionResult();
} }
if (!Guid.TryParse(record.ItemId, out var itemId)) if (!Guid.TryParse(record.ItemId, out var itemId))
{ {
await HandleFailureAsync(record, "Shared item snapshot is invalid.", cancellationToken).ConfigureAwait(false); await HandleFailureAsync(record, "Shared item snapshot is invalid.", cancellationToken).ConfigureAwait(false);
return null; return new ShareLinkRedemptionResult();
} }
var item = _libraryManager.GetItemById(itemId); var item = _libraryManager.GetItemById(itemId);
if (item is null) if (item is null)
{ {
await HandleFailureAsync(record, "Shared item no longer exists.", cancellationToken).ConfigureAwait(false); await HandleFailureAsync(record, "Shared item no longer exists.", cancellationToken).ConfigureAwait(false);
return null; return new ShareLinkRedemptionResult();
} }
if (!string.IsNullOrWhiteSpace(record.AllowedTag)) if (!string.IsNullOrWhiteSpace(record.AllowedTag))
@@ -117,7 +131,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");
} }
@@ -158,6 +176,17 @@ public sealed class ShareLinkRedemptionService
record.CleanupError = null; record.CleanupError = null;
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false); await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
} }
catch (SecurityException ex)
{
// The link is fine, the guest account has simply reached its viewer
// ceiling. Leave the record and the guest alone: marking this failed
// would tear down the account and throw out everyone already watching.
record.Status = record.RedeemedAtUtc.HasValue ? ShareLinkStatus.Redeemed : ShareLinkStatus.Active;
record.CleanupError = null;
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(ex, "ShareLinks: record {RecordId} is at its viewer ceiling; turning a viewer away.", record.Id);
return new ShareLinkRedemptionResult { AtCapacity = true };
}
catch (Exception ex) catch (Exception ex)
{ {
record.Status = ShareLinkStatus.Failed; record.Status = ShareLinkStatus.Failed;
@@ -165,10 +194,10 @@ public sealed class ShareLinkRedemptionService
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false); await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
_logger.LogWarning(ex, "ShareLinks: failed to prepare guest session for record {RecordId}.", record.Id); _logger.LogWarning(ex, "ShareLinks: failed to prepare guest session for record {RecordId}.", record.Id);
await TryCleanupAsync(record, cancellationToken).ConfigureAwait(false); await TryCleanupAsync(record, cancellationToken).ConfigureAwait(false);
return null; return new ShareLinkRedemptionResult();
} }
return BuildBootstrapHtml(request, authResult, itemId); return new ShareLinkRedemptionResult { Html = BuildBootstrapHtml(request, authResult, itemId) };
} }
private async Task HandleTerminalRecordAsync(ShareLinkRecord record, ShareLinkStatus terminalStatus, string reason, CancellationToken cancellationToken) private async Task HandleTerminalRecordAsync(ShareLinkRecord record, ShareLinkStatus terminalStatus, string reason, CancellationToken cancellationToken)
+9 -1
Voir le fichier
@@ -101,8 +101,14 @@
<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 class="sl-field inputContainer">
<input is="emby-input" type="number" id="MaxConcurrentViewers" label="Maximum viewers per multi-use link" min="0" max="500" />
<div class="fieldDescription">How many people may watch a multi-use link at the same time. 0 means no limit. Someone arriving once the limit is reached is asked to try again later; nobody already watching is disturbed. Single-use links are always one viewer.</div>
</div> </div>
<div class="sl-field inputContainer"> <div class="sl-field inputContainer">
@@ -217,6 +223,7 @@
page.querySelector('#AllowRemuxing').checked = cfg.AllowRemuxing !== false; page.querySelector('#AllowRemuxing').checked = cfg.AllowRemuxing !== false;
page.querySelector('#CleanupIntervalMinutes').value = cfg.CleanupIntervalMinutes || 60; page.querySelector('#CleanupIntervalMinutes').value = cfg.CleanupIntervalMinutes || 60;
page.querySelector('#OneUseDefault').checked = cfg.OneUseDefault !== false; page.querySelector('#OneUseDefault').checked = cfg.OneUseDefault !== false;
page.querySelector('#MaxConcurrentViewers').value = cfg.MaxConcurrentViewers === undefined ? 10 : cfg.MaxConcurrentViewers;
page.querySelector('#GuestModeLockdownEnabled').checked = cfg.GuestModeLockdownEnabled !== false; page.querySelector('#GuestModeLockdownEnabled').checked = cfg.GuestModeLockdownEnabled !== false;
}).finally(function () { }).finally(function () {
Dashboard.hideLoadingMsg(); Dashboard.hideLoadingMsg();
@@ -398,6 +405,7 @@
cfg.AllowRemuxing = page.querySelector('#AllowRemuxing').checked; cfg.AllowRemuxing = page.querySelector('#AllowRemuxing').checked;
cfg.CleanupIntervalMinutes = parseInt(page.querySelector('#CleanupIntervalMinutes').value, 10) || 60; cfg.CleanupIntervalMinutes = parseInt(page.querySelector('#CleanupIntervalMinutes').value, 10) || 60;
cfg.OneUseDefault = page.querySelector('#OneUseDefault').checked; cfg.OneUseDefault = page.querySelector('#OneUseDefault').checked;
cfg.MaxConcurrentViewers = Math.max(parseInt(page.querySelector('#MaxConcurrentViewers').value, 10) || 0, 0);
cfg.GuestModeLockdownEnabled = page.querySelector('#GuestModeLockdownEnabled').checked; cfg.GuestModeLockdownEnabled = page.querySelector('#GuestModeLockdownEnabled').checked;
ApiClient.updatePluginConfiguration(ShareLinksPluginId, cfg).then(function (result) { ApiClient.updatePluginConfiguration(ShareLinksPluginId, cfg).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result); Dashboard.processPluginConfigurationUpdateResult(result);
+78 -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-2';
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,10 @@
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.',
multiUseLimit: 'Up to {count} of them can watch at the same time.',
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 +93,10 @@
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.',
multiUseLimit: 'Jusqu\'a {count} d\'entre elles peuvent regarder en meme temps.',
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 +1071,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 +1094,8 @@
}).then(function (copied) { }).then(function (copied) {
return { return {
shareUrl: shareUrl, shareUrl: shareUrl,
copied: copied copied: copied,
multiUse: !!multiUse
}; };
}); });
}); });
@@ -1095,7 +1104,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 +1143,11 @@
options: options, options: options,
onChoose: onChoose, onChoose: onChoose,
cancelText: t('cancel'), cancelText: t('cancel'),
toggle: {
label: t('multiUseLabel'),
hint: buildMultiUseHint(config),
checked: !(config && config.OneUseDefault !== false)
},
datePicker: { datePicker: {
min: toLocalDatetimeValue(minDate), min: toLocalDatetimeValue(minDate),
max: toLocalDatetimeValue(maxDate), max: toLocalDatetimeValue(maxDate),
@@ -1143,6 +1157,16 @@
}); });
} }
function buildMultiUseHint(config) {
var hint = t('multiUseHint');
var limit = config ? parseInt(config.MaxConcurrentViewers, 10) : NaN;
if (Number.isFinite(limit) && limit > 0) {
hint += ' ' + t('multiUseLimit').replace('{count}', limit);
}
return hint;
}
function copyTextWhenReady(textPromise) { function copyTextWhenReady(textPromise) {
if (navigator.clipboard && navigator.clipboard.write && window.ClipboardItem && window.Blob) { if (navigator.clipboard && navigator.clipboard.write && window.ClipboardItem && window.Blob) {
try { try {
@@ -1193,7 +1217,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 +1227,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 +1307,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 +1375,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 +1406,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 +1524,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"
} }
+37 -2
Voir le fichier
@@ -30,7 +30,12 @@ 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. A multi-use link has a ceiling on how many people can
watch at the same time, ten by default, and the eleventh is asked to try again
later rather than displacing anyone.
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
@@ -114,6 +119,35 @@ refuses every interactive sign-in, so the normal login page cannot be used to ge
into a guest account at all, password or not. If the plugin is disabled Jellyfin into a guest account at all, password or not. If the plugin is disabled Jellyfin
falls back to its own invalid-provider handling, which refuses too. falls back to its own invalid-provider handling, which refuses too.
### What a multi-use link does and does not protect
A multi-use link is by design usable by anyone you send it to, so treat the URL
itself as the secret. Within that:
- The tag policy is per account and the account is the same one, so every viewer
still sees exactly the shared title and nothing else. Letting more people in
does not widen what any of them can reach.
- The viewer ceiling caps how many people can *start* watching at once. It is not
a hard cap on how many people ever get in: sessions end, and each redemption
issues its own session token which keeps working until the link is revoked or
expires. If you need a hard stop, revoke the link.
- Everyone shares one temporary account, so they share playback position and
watched state on that title, and they can see each other's sessions in Jellyfin.
If that matters to you, use single-use links.
- Reaching the ceiling turns the new arrival away with a "try again" page. It does
not disturb anyone already watching, and it does not kill the link.
### Known limits
- The share token travels in the link's query string, so it will appear in your
reverse proxy's access log and in browser history.
- Redeeming is a public endpoint with no rate limit. Tokens are 256-bit random, so
guessing one is not realistic, but the endpoint is reachable by anyone.
- Records are kept after they expire, for audit, and are never pruned.
- The `sharelinks-` tag is hidden from non-admins in the web client only. It is
still present in the API response for anyone who looks, because that tag is what
confines the guest and it cannot be removed without removing the confinement.
## Configuration ## Configuration
All of these live on the plugin's dashboard page: All of these live on the plugin's dashboard page:
@@ -125,7 +159,8 @@ 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 | | Maximum viewers per multi-use link | How many people may watch one multi-use link at the same time (default 10, 0 means no limit) |
| 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 |