From f0b9e8351b862391756753cb938b93ee2c74e305 Mon Sep 17 00:00:00 2001 From: Franciskid Date: Sun, 26 Jul 2026 21:05:54 +0200 Subject: [PATCH 1/2] 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. --- .../Jellyfin.Plugin.ShareLinks.csproj | 6 +- .../Services/JellyfinGuestUserService.cs | 4 +- .../Services/ShareLinkRedemptionService.cs | 6 +- .../Web/configPage.html | 3 +- Jellyfin.Plugin.ShareLinks/Web/sharelinks.js | 74 +++++++++++++++++-- Jellyfin.Plugin.ShareLinks/meta.json | 4 +- README.md | 7 +- 7 files changed, 86 insertions(+), 18 deletions(-) diff --git a/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj b/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj index a717594..672176a 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.2.0 - 1.0.2.0 - 1.0.2.0 + 1.0.3.0 + 1.0.3.0 + 1.0.3.0 true false disable diff --git a/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs b/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs index 62a40d7..e78cc3b 100644 --- a/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs +++ b/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs @@ -187,7 +187,9 @@ public sealed class JellyfinGuestUserService EnabledFolders = Array.Empty(), EnablePublicSharing = false, 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() }; 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 @@
+
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.
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 | From 224a79f7e55df0af0caa017398e54567d6a35d73 Mon Sep 17 00:00:00 2001 From: Franciskid Date: Sun, 26 Jul 2026 21:22:20 +0200 Subject: [PATCH 2/2] cap how many people can watch one multi-use link at once New setting, ten by default, zero for no limit. Single-use links are unaffected, they are one viewer by definition. The catch is what happens at the ceiling. Jellyfin throws SecurityException once a user is at MaxActiveSessions, and that was landing in the generic handler, which marks the record failed and runs cleanup, which deletes the guest account. So without care, adding a ceiling would mean the eleventh person to open a link kicks out the ten already watching and destroys the link. Capacity is caught separately now: the record goes back to the state it was in, nothing is torn down, and the new arrival gets a 503 page inviting them to try again. Worth being honest that this caps how many people can start watching at once, not how many ever get in: each redemption issues its own session token that keeps working until the link is revoked or expires. Revoke is still the hard stop. README picks up the multi-use option, the new setting, and a section on what a multi-use link does and does not protect, plus the known limits around the token in the query string, the unthrottled redeem endpoint, and the tag being hidden in the web UI only. --- .../Api/ShareLinksController.cs | 50 +++++++++++++++++-- .../Configuration/PluginConfiguration.cs | 6 +++ .../Services/JellyfinGuestUserService.cs | 6 +-- .../Services/ShareLinkRedemptionService.cs | 49 +++++++++++++----- .../Web/configPage.html | 7 +++ Jellyfin.Plugin.ShareLinks/Web/sharelinks.js | 16 +++++- README.md | 34 ++++++++++++- 7 files changed, 147 insertions(+), 21 deletions(-) diff --git a/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs b/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs index c37238a..096f24b 100644 --- a/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs +++ b/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs @@ -326,13 +326,18 @@ public sealed class ShareLinksController : ControllerBase return LinkUnavailablePage(Request); } - var html = await _redemptionService.RedeemAsync(token, Request, cancellationToken).ConfigureAwait(false); - if (html is null) + var result = await _redemptionService.RedeemAsync(token, Request, cancellationToken).ConfigureAwait(false); + if (result.AtCapacity) + { + return LinkBusyPage(); + } + + if (result.Html is null) { 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) @@ -380,6 +385,45 @@ setTimeout(function () { window.location.replace({{redirectUrlJson}}); }, 4000); }; } + /// + /// 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. + /// + private static ContentResult LinkBusyPage() + { + var html = """ + + + + + + Too many viewers + + + +
+
This link is being watched by as many people as it allows right now.
+
Ce lien est deja utilise par autant de personnes qu'il l'autorise.
+

Try again

+
+ + +"""; + + return new ContentResult + { + StatusCode = StatusCodes.Status503ServiceUnavailable, + ContentType = "text/html; charset=utf-8", + Content = html + }; + } + private static ShareLinkAdminRecordDto ToDto(ShareLinkRecord record) { return new ShareLinkAdminRecordDto diff --git a/Jellyfin.Plugin.ShareLinks/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.ShareLinks/Configuration/PluginConfiguration.cs index f4839ed..5a68fcd 100644 --- a/Jellyfin.Plugin.ShareLinks/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.ShareLinks/Configuration/PluginConfiguration.cs @@ -39,6 +39,12 @@ public class PluginConfiguration : BasePluginConfiguration /// Gets or sets a value indicating whether links default to one use. public bool OneUseDefault { get; set; } = true; + /// + /// 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. + /// + public int MaxConcurrentViewers { get; set; } = 10; + /// Gets or sets a value indicating whether guest-mode lockdown is enabled. public bool GuestModeLockdownEnabled { get; set; } = true; diff --git a/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs b/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs index e78cc3b..bf7b774 100644 --- a/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs +++ b/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs @@ -187,9 +187,9 @@ public sealed class JellyfinGuestUserService EnabledFolders = Array.Empty(), EnablePublicSharing = false, LoginAttemptsBeforeLockout = -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, + // 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() }; diff --git a/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs index 576f15a..6ccf55d 100644 --- a/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs +++ b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs @@ -1,4 +1,5 @@ using System; +using System.Security; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -13,6 +14,19 @@ using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.ShareLinks.Services; +/// Outcome of a redemption attempt. +public sealed class ShareLinkRedemptionResult +{ + /// Gets the bootstrap HTML when a session was minted, otherwise null. + public string? Html { get; init; } + + /// + /// Gets a value indicating whether the link is valid but already has as many + /// viewers as it is allowed to have. + /// + public bool AtCapacity { get; init; } +} + /// Handles public share-link redemption and the bootstrap HTML response. public sealed class ShareLinkRedemptionService { @@ -47,8 +61,8 @@ public sealed class ShareLinkRedemptionService _logger = logger; } - /// Redeems a token and returns the bootstrap HTML, or null if the token is unusable. - public async Task RedeemAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken) + /// Redeems a token and returns the redemption result. + public async Task RedeemAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken) { // 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 @@ -65,50 +79,50 @@ public sealed class ShareLinkRedemptionService } /// Runs a single redemption; callers must hold the redemption gate. - private async Task RedeemInternalAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken) + private async Task RedeemInternalAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken) { var tokenHash = await _tokenService.HashTokenAsync(rawToken, cancellationToken).ConfigureAwait(false); if (tokenHash is null) { - return null; + return new ShareLinkRedemptionResult(); } var record = await _store.GetByTokenHashAsync(tokenHash, cancellationToken).ConfigureAwait(false); if (record is null) { - return null; + return new ShareLinkRedemptionResult(); } var now = DateTimeOffset.UtcNow; if (record.ExpiresAtUtc <= now) { 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) { - return null; + return new ShareLinkRedemptionResult(); } // 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. if (record.OneUse && record.Status == ShareLinkStatus.Redeemed) { - return null; + return new ShareLinkRedemptionResult(); } if (!Guid.TryParse(record.ItemId, out var itemId)) { await HandleFailureAsync(record, "Shared item snapshot is invalid.", cancellationToken).ConfigureAwait(false); - return null; + return new ShareLinkRedemptionResult(); } var item = _libraryManager.GetItemById(itemId); if (item is null) { await HandleFailureAsync(record, "Shared item no longer exists.", cancellationToken).ConfigureAwait(false); - return null; + return new ShareLinkRedemptionResult(); } if (!string.IsNullOrWhiteSpace(record.AllowedTag)) @@ -162,6 +176,17 @@ public sealed class ShareLinkRedemptionService record.CleanupError = null; 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) { record.Status = ShareLinkStatus.Failed; @@ -169,10 +194,10 @@ public sealed class ShareLinkRedemptionService await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false); _logger.LogWarning(ex, "ShareLinks: failed to prepare guest session for record {RecordId}.", record.Id); 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) diff --git a/Jellyfin.Plugin.ShareLinks/Web/configPage.html b/Jellyfin.Plugin.ShareLinks/Web/configPage.html index 8581f6f..a69f826 100644 --- a/Jellyfin.Plugin.ShareLinks/Web/configPage.html +++ b/Jellyfin.Plugin.ShareLinks/Web/configPage.html @@ -106,6 +106,11 @@
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.
+
+ +
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.
+
+
Used by the menu action when the admin accepts the default.
@@ -218,6 +223,7 @@ page.querySelector('#AllowRemuxing').checked = cfg.AllowRemuxing !== false; page.querySelector('#CleanupIntervalMinutes').value = cfg.CleanupIntervalMinutes || 60; page.querySelector('#OneUseDefault').checked = cfg.OneUseDefault !== false; + page.querySelector('#MaxConcurrentViewers').value = cfg.MaxConcurrentViewers === undefined ? 10 : cfg.MaxConcurrentViewers; page.querySelector('#GuestModeLockdownEnabled').checked = cfg.GuestModeLockdownEnabled !== false; }).finally(function () { Dashboard.hideLoadingMsg(); @@ -399,6 +405,7 @@ cfg.AllowRemuxing = page.querySelector('#AllowRemuxing').checked; cfg.CleanupIntervalMinutes = parseInt(page.querySelector('#CleanupIntervalMinutes').value, 10) || 60; cfg.OneUseDefault = page.querySelector('#OneUseDefault').checked; + cfg.MaxConcurrentViewers = Math.max(parseInt(page.querySelector('#MaxConcurrentViewers').value, 10) || 0, 0); cfg.GuestModeLockdownEnabled = page.querySelector('#GuestModeLockdownEnabled').checked; ApiClient.updatePluginConfiguration(ShareLinksPluginId, cfg).then(function (result) { Dashboard.processPluginConfigurationUpdateResult(result); diff --git a/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js b/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js index 68720b9..38fc769 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.3-ui-1'; + var clientVersion = '1.0.3-ui-2'; var allowedItemStorageKey = 'sharelinks.allowedItemId'; var guestClassName = 'sharelinks-guest'; var hiddenAttr = 'data-sharelinks-hidden'; @@ -67,6 +67,7 @@ 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.', adminOnly: 'ShareLinks is available to administrators only.', @@ -94,6 +95,7 @@ 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.', adminOnly: 'ShareLinks est réservé aux administrateurs.', @@ -1143,7 +1145,7 @@ cancelText: t('cancel'), toggle: { label: t('multiUseLabel'), - hint: t('multiUseHint'), + hint: buildMultiUseHint(config), checked: !(config && config.OneUseDefault !== false) }, datePicker: { @@ -1155,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) { if (navigator.clipboard && navigator.clipboard.write && window.ClipboardItem && window.Blob) { try { diff --git a/README.md b/README.md index 0525997..1daacb6 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,9 @@ real user or handing over a login that sees everything. 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. + 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 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 @@ -117,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 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 All of these live on the plugin's dashboard page: @@ -128,6 +159,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 | +| 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 hidden selectors | CSS selectors hidden from guests, to suppress other plugins' UI |