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.
+
+
+
+""";
+
+ 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 |