diff --git a/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj b/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj
index 672176a..c3e6ad3 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.3.0
- 1.0.3.0
- 1.0.3.0
+ 1.0.3.1
+ 1.0.3.1
+ 1.0.3.1
true
false
disable
diff --git a/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs
index 6ccf55d..8d1a44f 100644
--- a/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs
+++ b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs
@@ -1,5 +1,5 @@
using System;
-using System.Security;
+using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -112,6 +112,19 @@ public sealed class ShareLinkRedemptionService
return new ShareLinkRedemptionResult();
}
+ // Turn an over-the-ceiling viewer away before anything is written: no tag
+ // work, no status change, no guest account touched. Jellyfin enforces the
+ // same limit itself when the session is created, but it does so by throwing,
+ // and a throw here would land in the failure path below and tear the whole
+ // share down on the people already watching.
+ if (IsAtViewerCeiling(record))
+ {
+ _logger.LogInformation(
+ "ShareLinks: record {RecordId} is at its viewer ceiling; turning a viewer away.",
+ record.Id);
+ return new ShareLinkRedemptionResult { AtCapacity = true };
+ }
+
if (!Guid.TryParse(record.ItemId, out var itemId))
{
await HandleFailureAsync(record, "Shared item snapshot is invalid.", cancellationToken).ConfigureAwait(false);
@@ -176,15 +189,17 @@ public sealed class ShareLinkRedemptionService
record.CleanupError = null;
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
}
- catch (SecurityException ex)
+ catch (MediaBrowser.Controller.Net.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.
+ // Backstop for the race between the ceiling check above and this call.
+ // Jellyfin's own type, NOT System.Security.SecurityException. The link is
+ // fine, so leave the record and the guest exactly as they were: marking
+ // this failed would tear the account down and throw out everyone already
+ // watching.
+ _logger.LogInformation(ex, "ShareLinks: record {RecordId} hit its viewer ceiling while creating the session.", record.Id);
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)
@@ -200,6 +215,32 @@ public sealed class ShareLinkRedemptionService
return new ShareLinkRedemptionResult { Html = BuildBootstrapHtml(request, authResult, itemId) };
}
+ ///
+ /// Returns true when the share already has as many viewers watching as it is
+ /// allowed. Mirrors the limit Jellyfin applies when it creates a session, so we
+ /// can refuse politely instead of letting it throw.
+ ///
+ private bool IsAtViewerCeiling(ShareLinkRecord record)
+ {
+ if (!record.GuestUserId.HasValue)
+ {
+ // Nobody has redeemed this link yet, so nothing can be at a ceiling.
+ return false;
+ }
+
+ var ceiling = record.OneUse
+ ? 1
+ : Math.Max(Plugin.Instance?.Configuration.MaxConcurrentViewers ?? 0, 0);
+ if (ceiling < 1)
+ {
+ // 0 means no limit, the same way Jellyfin reads it.
+ return false;
+ }
+
+ var guestUserId = record.GuestUserId.Value;
+ return _sessionManager.Sessions.Count(session => session.UserId.Equals(guestUserId)) >= ceiling;
+ }
+
private async Task HandleTerminalRecordAsync(ShareLinkRecord record, ShareLinkStatus terminalStatus, string reason, CancellationToken cancellationToken)
{
record.Status = terminalStatus;
diff --git a/Jellyfin.Plugin.ShareLinks/meta.json b/Jellyfin.Plugin.ShareLinks/meta.json
index fa65dc0..8e8a060 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.3.0",
+ "version": "1.0.3.1",
"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-26T19:30:00.0000000Z"
+ "timestamp": "2026-07-26T20:00:00.0000000Z"
}