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