commit c29ea7f20cd05bd84e2101c9dc8a2d81ca5806bf
Author: Franciskid
Date: Mon Jul 6 18:21:22 2026 +0200
ShareLinks plugin: guest share links for Jellyfin
Includes fix for redemption failing with DbUpdateConcurrencyException:
change the guest password before applying the user policy, since
UpdatePolicyAsync bumps the user's EF concurrency token and a stale
instance then breaks ChangePassword.
diff --git a/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs b/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs
new file mode 100644
index 0000000..cb782c6
--- /dev/null
+++ b/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs
@@ -0,0 +1,374 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Security.Claims;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Plugin.ShareLinks.Configuration;
+using Jellyfin.Plugin.ShareLinks.Models;
+using Jellyfin.Plugin.ShareLinks.Services;
+using Jellyfin.Plugin.ShareLinks.Storage;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Plugin.ShareLinks.Api;
+
+/// Request body for ShareLinks admin creation.
+public sealed class ShareLinkCreateRequest
+{
+ /// Gets or sets the Jellyfin item id.
+ public string? ItemId { get; set; }
+
+ /// Gets or sets an optional expiry in hours.
+ public int? ExpiryHours { get; set; }
+
+ /// Gets or sets whether the link may be redeemed once only.
+ public bool? OneUse { get; set; }
+}
+
+/// Admin response for a created ShareLinks record.
+public sealed class ShareLinkCreateResponse
+{
+ /// Gets or sets the raw share URL.
+ public string ShareUrl { get; set; } = string.Empty;
+
+ /// Gets or sets the created record snapshot.
+ public ShareLinkAdminRecordDto Record { get; set; } = new();
+}
+
+/// DTO returned by admin list and revoke endpoints.
+public sealed class ShareLinkAdminRecordDto
+{
+ public Guid Id { get; set; }
+
+ public string ItemId { get; set; } = string.Empty;
+
+ public string ItemNameSnapshot { get; set; } = string.Empty;
+
+ public string? LibraryId { get; set; }
+
+ public Guid? CreatedByUserId { get; set; }
+
+ public DateTimeOffset CreatedAtUtc { get; set; }
+
+ public DateTimeOffset? RedeemedAtUtc { get; set; }
+
+ public DateTimeOffset ExpiresAtUtc { get; set; }
+
+ public ShareLinkStatus Status { get; set; }
+
+ public Guid? GuestUserId { get; set; }
+
+ public string? GuestUserName { get; set; }
+
+ public string? AllowedTag { get; set; }
+
+ public bool OneUse { get; set; }
+
+ public bool MetadataTouched { get; set; }
+
+ public int CleanupAttempts { get; set; }
+
+ public string? CleanupError { get; set; }
+}
+
+/// Guest session state returned to the web client.
+public sealed class ShareLinkGuestStateDto
+{
+ public bool IsGuest { get; set; }
+
+ public string? AllowedItemId { get; set; }
+
+ public Guid? ShareId { get; set; }
+
+ public DateTimeOffset? ExpiresAtUtc { get; set; }
+
+ public bool LockdownEnabled { get; set; }
+}
+
+/// ShareLinks API surface.
+[ApiController]
+[Route("ShareLinks")]
+public sealed class ShareLinksController : ControllerBase
+{
+ private readonly ILibraryManager _libraryManager;
+ private readonly ShareLinkCreationService _creationService;
+ private readonly ShareLinkCleanupService _cleanupService;
+ private readonly ShareLinkRedemptionService _redemptionService;
+ private readonly ShareLinkStore _store;
+ private readonly ILogger _logger;
+
+ /// Initializes a new instance of the class.
+ public ShareLinksController(
+ ILibraryManager libraryManager,
+ ShareLinkCreationService creationService,
+ ShareLinkCleanupService cleanupService,
+ ShareLinkRedemptionService redemptionService,
+ ShareLinkStore store,
+ ILogger logger)
+ {
+ _libraryManager = libraryManager;
+ _creationService = creationService;
+ _cleanupService = cleanupService;
+ _redemptionService = redemptionService;
+ _store = store;
+ _logger = logger;
+ }
+
+ private static PluginConfiguration Config => Plugin.Instance!.Configuration;
+
+ /// Serves the client-side ShareLinks script.
+ [HttpGet("ClientScript")]
+ [AllowAnonymous]
+ public ActionResult ClientScript()
+ {
+ SetNoStoreHeaders();
+
+ var assembly = typeof(ShareLinksController).Assembly;
+ var resourceName = assembly.GetManifestResourceNames()
+ .FirstOrDefault(name => name.EndsWith(".Web.sharelinks.js", StringComparison.OrdinalIgnoreCase));
+ if (resourceName is null)
+ {
+ return NotFound();
+ }
+
+ using var stream = assembly.GetManifestResourceStream(resourceName);
+ if (stream is null)
+ {
+ return NotFound();
+ }
+
+ using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
+ return Content(reader.ReadToEnd(), "application/javascript; charset=utf-8");
+ }
+
+ /// Creates a new share link for an item.
+ [HttpPost("Admin/Create")]
+ [Authorize(AuthenticationSchemes = "CustomAuthentication")]
+ public async Task> Create([FromBody] ShareLinkCreateRequest request, CancellationToken cancellationToken)
+ {
+ SetNoStoreHeaders();
+ if (!User.IsInRole("Administrator"))
+ {
+ return Forbid();
+ }
+
+ var config = Config;
+ if (!config.Enabled)
+ {
+ return StatusCode(503, new { error = "ShareLinks is disabled." });
+ }
+
+ if (request is null || string.IsNullOrWhiteSpace(request.ItemId))
+ {
+ return BadRequest(new { error = "Missing itemId." });
+ }
+
+ if (!Guid.TryParse(request.ItemId, out var itemId))
+ {
+ return BadRequest(new { error = "Invalid itemId." });
+ }
+
+ var expiryHours = request.ExpiryHours ?? config.DefaultExpiryHours;
+ if (expiryHours <= 0)
+ {
+ return BadRequest(new { error = "Expiry must be positive." });
+ }
+
+ var effectiveMaxExpiryHours = Math.Max(config.MaxExpiryHours, 720);
+ if (expiryHours > effectiveMaxExpiryHours)
+ {
+ return BadRequest(new { error = $"Expiry exceeds the configured maximum of {effectiveMaxExpiryHours} hours." });
+ }
+
+ var item = _libraryManager.GetItemById(itemId);
+ if (item is null)
+ {
+ return NotFound(new { error = "Item not found." });
+ }
+
+ try
+ {
+ var creatorUserId = GetCurrentUserId();
+ var oneUse = request.OneUse ?? config.OneUseDefault;
+ var creation = await _creationService.CreateAsync(item, creatorUserId, expiryHours, oneUse, cancellationToken).ConfigureAwait(false);
+ var shareUrl = BuildShareUrl(Request, creation.RawToken);
+ return Ok(new ShareLinkCreateResponse
+ {
+ ShareUrl = shareUrl,
+ Record = ToDto(creation.Record)
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "ShareLinks: create failed for item {ItemId}.", itemId);
+ return StatusCode(500, new { error = "Failed to create share link." });
+ }
+ }
+
+ /// Lists all share links for administrators.
+ [HttpGet("Admin/List")]
+ [Authorize(AuthenticationSchemes = "CustomAuthentication")]
+ public async Task>> List(CancellationToken cancellationToken)
+ {
+ SetNoStoreHeaders();
+ if (!User.IsInRole("Administrator"))
+ {
+ return Forbid();
+ }
+
+ var records = await _store.ListAsync(cancellationToken).ConfigureAwait(false);
+ return Ok(records.Select(ToDto).ToArray());
+ }
+
+ /// Revokes a share link and triggers cleanup.
+ [HttpPost("Admin/Revoke/{id:guid}")]
+ [Authorize(AuthenticationSchemes = "CustomAuthentication")]
+ public async Task> Revoke(Guid id, CancellationToken cancellationToken)
+ {
+ SetNoStoreHeaders();
+ if (!User.IsInRole("Administrator"))
+ {
+ return Forbid();
+ }
+
+ var record = await _cleanupService.RevokeAsync(id, cancellationToken).ConfigureAwait(false);
+ if (record is null)
+ {
+ return NotFound(new { error = "Share link not found." });
+ }
+
+ return Ok(ToDto(record));
+ }
+
+ /// Returns the guest session state for the current authenticated user.
+ [HttpGet("GuestState")]
+ [Authorize(AuthenticationSchemes = "CustomAuthentication")]
+ public async Task> GuestState(CancellationToken cancellationToken)
+ {
+ SetNoStoreHeaders();
+
+ var config = Config;
+ var currentUserId = GetCurrentUserId();
+ var currentUserName = GetCurrentUserName();
+
+ if (currentUserId != Guid.Empty || !string.IsNullOrWhiteSpace(currentUserName))
+ {
+ var records = await _store.ListAsync(cancellationToken).ConfigureAwait(false);
+ var match = records.FirstOrDefault(record =>
+ !IsExpired(record) &&
+ IsGuestSessionStatus(record.Status) &&
+ (
+ (currentUserId != Guid.Empty && record.GuestUserId.HasValue && record.GuestUserId.Value == currentUserId) ||
+ (!string.IsNullOrWhiteSpace(currentUserName) &&
+ !string.IsNullOrWhiteSpace(record.GuestUserName) &&
+ string.Equals(record.GuestUserName, currentUserName, StringComparison.OrdinalIgnoreCase))
+ ));
+
+ if (match is not null)
+ {
+ return Ok(new ShareLinkGuestStateDto
+ {
+ IsGuest = true,
+ AllowedItemId = match.ItemId,
+ ShareId = match.Id,
+ ExpiresAtUtc = match.ExpiresAtUtc,
+ LockdownEnabled = config.GuestModeLockdownEnabled
+ });
+ }
+ }
+
+ return Ok(new ShareLinkGuestStateDto
+ {
+ IsGuest = false,
+ LockdownEnabled = config.GuestModeLockdownEnabled
+ });
+ }
+
+ /// Redeems a share link token and returns the bootstrap login page.
+ [HttpGet("Redeem")]
+ [AllowAnonymous]
+ public async Task Redeem([FromQuery(Name = "t")] string? token, CancellationToken cancellationToken)
+ {
+ SetNoStoreHeaders();
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ return NotFound();
+ }
+
+ var html = await _redemptionService.RedeemAsync(token, Request, cancellationToken).ConfigureAwait(false);
+ if (html is null)
+ {
+ return NotFound();
+ }
+
+ return Content(html, "text/html; charset=utf-8");
+ }
+
+ private static ShareLinkAdminRecordDto ToDto(ShareLinkRecord record)
+ {
+ return new ShareLinkAdminRecordDto
+ {
+ Id = record.Id,
+ ItemId = record.ItemId,
+ ItemNameSnapshot = record.ItemNameSnapshot,
+ LibraryId = record.LibraryId,
+ CreatedByUserId = record.CreatedByUserId,
+ CreatedAtUtc = record.CreatedAtUtc,
+ RedeemedAtUtc = record.RedeemedAtUtc,
+ ExpiresAtUtc = record.ExpiresAtUtc,
+ Status = record.Status,
+ GuestUserId = record.GuestUserId,
+ GuestUserName = record.GuestUserName,
+ AllowedTag = record.AllowedTag,
+ OneUse = record.OneUse,
+ MetadataTouched = record.MetadataTouched,
+ CleanupAttempts = record.CleanupAttempts,
+ CleanupError = record.CleanupError
+ };
+ }
+
+ private Guid GetCurrentUserId()
+ {
+ var claim = User.FindFirst("Jellyfin-UserId")?.Value ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
+ return Guid.TryParse(claim, out var id) ? id : Guid.Empty;
+ }
+
+ private string? GetCurrentUserName()
+ {
+ return User.FindFirst("Jellyfin-UserName")?.Value
+ ?? User.FindFirst(ClaimTypes.Name)?.Value
+ ?? User.Identity?.Name;
+ }
+
+ private static bool IsExpired(ShareLinkRecord record)
+ {
+ return record.ExpiresAtUtc <= DateTimeOffset.UtcNow;
+ }
+
+ private static bool IsGuestSessionStatus(ShareLinkStatus status)
+ {
+ return status is ShareLinkStatus.Active or ShareLinkStatus.Redeeming or ShareLinkStatus.Redeemed;
+ }
+
+ private void SetNoStoreHeaders()
+ {
+ Response.Headers["Cache-Control"] = "no-store, no-cache, max-age=0, must-revalidate";
+ Response.Headers["Pragma"] = "no-cache";
+ }
+
+ private static string BuildShareUrl(Microsoft.AspNetCore.Http.HttpRequest request, string rawToken)
+ {
+ var config = Config;
+ var baseUrl = string.IsNullOrWhiteSpace(config.PublicBaseUrlOverride)
+ ? $"{request.Scheme}://{request.Host}{request.PathBase}"
+ : config.PublicBaseUrlOverride.TrimEnd('/');
+
+ return $"{baseUrl.TrimEnd('/')}/ShareLinks/Redeem?t={Uri.EscapeDataString(rawToken)}";
+ }
+}
diff --git a/Jellyfin.Plugin.ShareLinks/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.ShareLinks/Configuration/PluginConfiguration.cs
new file mode 100644
index 0000000..dbc3799
--- /dev/null
+++ b/Jellyfin.Plugin.ShareLinks/Configuration/PluginConfiguration.cs
@@ -0,0 +1,44 @@
+using MediaBrowser.Model.Plugins;
+
+namespace Jellyfin.Plugin.ShareLinks.Configuration;
+
+///
+/// Plugin configuration persisted by Jellyfin.
+///
+public class PluginConfiguration : BasePluginConfiguration
+{
+ /// Gets or sets a value indicating whether the plugin is enabled.
+ public bool Enabled { get; set; } = true;
+
+ /// Gets or sets the default share expiry in hours.
+ public int DefaultExpiryHours { get; set; } = 24;
+
+ /// Gets or sets the maximum allowed share expiry in hours.
+ public int MaxExpiryHours { get; set; } = 720;
+
+ ///
+ /// Gets or sets an override for the public base URL used when building
+ /// absolute share links. Empty means "derive from the incoming request".
+ ///
+ public string PublicBaseUrlOverride { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the prefix used when creating guest user names.
+ ///
+ public string GuestUsernamePrefix { get; set; } = "share-";
+
+ /// Gets or sets a value indicating whether shares may transcode.
+ public bool AllowTranscoding { get; set; } = true;
+
+ /// Gets or sets a value indicating whether shares may remux.
+ public bool AllowRemuxing { get; set; } = true;
+
+ /// Gets or sets the cleanup interval, in minutes.
+ public int CleanupIntervalMinutes { get; set; } = 60;
+
+ /// Gets or sets a value indicating whether links default to one use.
+ public bool OneUseDefault { get; set; } = true;
+
+ /// Gets or sets a value indicating whether guest-mode lockdown is enabled.
+ public bool GuestModeLockdownEnabled { get; set; } = true;
+}
diff --git a/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj b/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj
new file mode 100644
index 0000000..8c3035b
--- /dev/null
+++ b/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net9.0
+ enable
+ latest
+ Jellyfin.Plugin.ShareLinks
+ Jellyfin.Plugin.ShareLinks
+ 1.0.0.0
+ 1.0.0.0
+ 1.0.0.0
+ true
+ false
+ disable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Jellyfin.Plugin.ShareLinks/Lifecycle/StartupCleanupHostedService.cs b/Jellyfin.Plugin.ShareLinks/Lifecycle/StartupCleanupHostedService.cs
new file mode 100644
index 0000000..55ee21b
--- /dev/null
+++ b/Jellyfin.Plugin.ShareLinks/Lifecycle/StartupCleanupHostedService.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Plugin.ShareLinks.Services;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Plugin.ShareLinks.Lifecycle;
+
+///
+/// Runs one cleanup pass at startup so stale records do not linger forever.
+///
+public sealed class StartupCleanupHostedService : BackgroundService
+{
+ private readonly IShareLinkCleanupService _cleanupService;
+ private readonly ILogger _logger;
+
+ /// Initializes a new instance of the class.
+ public StartupCleanupHostedService(
+ IShareLinkCleanupService cleanupService,
+ ILogger logger)
+ {
+ _cleanupService = cleanupService;
+ _logger = logger;
+ }
+
+ ///
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ try
+ {
+ await _cleanupService.CleanupAsync(stoppingToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "ShareLinks: startup cleanup failed.");
+ }
+ }
+}
diff --git a/Jellyfin.Plugin.ShareLinks/Models/ShareLinkRecord.cs b/Jellyfin.Plugin.ShareLinks/Models/ShareLinkRecord.cs
new file mode 100644
index 0000000..d02cd84
--- /dev/null
+++ b/Jellyfin.Plugin.ShareLinks/Models/ShareLinkRecord.cs
@@ -0,0 +1,70 @@
+using System;
+
+namespace Jellyfin.Plugin.ShareLinks.Models;
+
+///
+/// Persistent share-link record. Only the token hash is stored; the raw token
+/// never enters durable storage.
+///
+public sealed class ShareLinkRecord
+{
+ /// Gets or sets the share-link id.
+ public Guid Id { get; set; } = Guid.NewGuid();
+
+ /// Gets or sets the HMAC hash of the token.
+ public string TokenHash { get; set; } = string.Empty;
+
+ /// Gets or sets the Jellyfin item id snapshot.
+ public string ItemId { get; set; } = string.Empty;
+
+ /// Gets or sets the Jellyfin item name snapshot.
+ public string ItemNameSnapshot { get; set; } = string.Empty;
+
+ /// Gets or sets the library id snapshot.
+ public string? LibraryId { get; set; }
+
+ /// Gets or sets the user id that created the link.
+ public Guid? CreatedByUserId { get; set; }
+
+ /// Gets or sets the UTC creation time.
+ public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
+
+ /// Gets or sets the UTC redemption time, if any.
+ public DateTimeOffset? RedeemedAtUtc { get; set; }
+
+ /// Gets or sets the UTC expiry time.
+ public DateTimeOffset ExpiresAtUtc { get; set; }
+
+ /// Gets or sets the current lifecycle status.
+ public ShareLinkStatus Status { get; set; } = ShareLinkStatus.Pending;
+
+ /// Gets or sets the guest user id associated with the link.
+ public Guid? GuestUserId { get; set; }
+
+ /// Gets or sets the guest user name associated with the link.
+ public string? GuestUserName { get; set; }
+
+ /// Gets or sets the access token id used by the guest session, if available.
+ public string? AccessTokenId { get; set; }
+
+ /// Gets or sets the device id used by the guest session, if available.
+ public string? DeviceId { get; set; }
+
+ /// Gets or sets the allowed tag snapshot, if any.
+ public string? AllowedTag { get; set; }
+
+ /// Gets or sets a value indicating whether the link may be used once only.
+ public bool OneUse { get; set; } = true;
+
+ /// Gets or sets the encrypted guest password, if one has been generated.
+ public string? GuestPasswordEncrypted { get; set; }
+
+ /// Gets or sets a value indicating whether metadata was touched during cleanup.
+ public bool MetadataTouched { get; set; }
+
+ /// Gets or sets the number of cleanup attempts performed on this record.
+ public int CleanupAttempts { get; set; }
+
+ /// Gets or sets the last cleanup error, if any.
+ public string? CleanupError { get; set; }
+}
diff --git a/Jellyfin.Plugin.ShareLinks/Models/ShareLinkStatus.cs b/Jellyfin.Plugin.ShareLinks/Models/ShareLinkStatus.cs
new file mode 100644
index 0000000..266f93a
--- /dev/null
+++ b/Jellyfin.Plugin.ShareLinks/Models/ShareLinkStatus.cs
@@ -0,0 +1,13 @@
+namespace Jellyfin.Plugin.ShareLinks.Models;
+
+/// Lifecycle state of a share link.
+public enum ShareLinkStatus
+{
+ Pending = 0,
+ Active = 1,
+ Redeemed = 2,
+ Expired = 3,
+ Revoked = 4,
+ Failed = 5,
+ Redeeming = 6
+}
diff --git a/Jellyfin.Plugin.ShareLinks/Models/ShareTokenMaterial.cs b/Jellyfin.Plugin.ShareLinks/Models/ShareTokenMaterial.cs
new file mode 100644
index 0000000..6cfc173
--- /dev/null
+++ b/Jellyfin.Plugin.ShareLinks/Models/ShareTokenMaterial.cs
@@ -0,0 +1,13 @@
+namespace Jellyfin.Plugin.ShareLinks.Models;
+
+///
+/// A freshly generated share token and its persisted hash.
+///
+public sealed class ShareTokenMaterial
+{
+ /// Gets or sets the raw token returned once to the caller.
+ public string Token { get; set; } = string.Empty;
+
+ /// Gets or sets the HMAC hash stored durably.
+ public string TokenHash { get; set; } = string.Empty;
+}
diff --git a/Jellyfin.Plugin.ShareLinks/Plugin.cs b/Jellyfin.Plugin.ShareLinks/Plugin.cs
new file mode 100644
index 0000000..c64872c
--- /dev/null
+++ b/Jellyfin.Plugin.ShareLinks/Plugin.cs
@@ -0,0 +1,46 @@
+using System;
+using System.Collections.Generic;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Plugins;
+using MediaBrowser.Model.Plugins;
+using MediaBrowser.Model.Serialization;
+using Jellyfin.Plugin.ShareLinks.Configuration;
+
+namespace Jellyfin.Plugin.ShareLinks;
+
+///
+/// ShareLinks plugin. Creates expiring guest-share links for Jellyfin items
+/// without persisting raw tokens.
+///
+public class Plugin : BasePlugin, IHasWebPages
+{
+ /// Initializes a new instance of the class.
+ public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
+ : base(applicationPaths, xmlSerializer)
+ {
+ Instance = this;
+ }
+
+ /// Gets the current plugin instance.
+ public static Plugin? Instance { get; private set; }
+
+ ///
+ public override string Name => "ShareLinks";
+
+ ///
+ public override string Description =>
+ "Secure expiring share links for Jellyfin items with guest-user lockdown.";
+
+ ///
+ public override Guid Id => Guid.Parse("68540b76-ee74-436d-85ff-2abc884bbea6");
+
+ ///
+ public IEnumerable GetPages() => new[]
+ {
+ new PluginPageInfo
+ {
+ Name = "ShareLinks",
+ EmbeddedResourcePath = GetType().Namespace + ".Web.configPage.html"
+ }
+ };
+}
diff --git a/Jellyfin.Plugin.ShareLinks/PluginServiceRegistrator.cs b/Jellyfin.Plugin.ShareLinks/PluginServiceRegistrator.cs
new file mode 100644
index 0000000..851d6d0
--- /dev/null
+++ b/Jellyfin.Plugin.ShareLinks/PluginServiceRegistrator.cs
@@ -0,0 +1,33 @@
+using Jellyfin.Plugin.ShareLinks.Lifecycle;
+using Jellyfin.Plugin.ShareLinks.Services;
+using Jellyfin.Plugin.ShareLinks.Storage;
+using Jellyfin.Plugin.ShareLinks.Web;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Plugins;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Jellyfin.Plugin.ShareLinks;
+
+///
+/// Registers the foundational ShareLinks services used by later API and web
+/// workers.
+///
+public class PluginServiceRegistrator : IPluginServiceRegistrator
+{
+ ///
+ public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
+ {
+ _ = applicationHost;
+
+ serviceCollection.AddHostedService();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton(provider => provider.GetRequiredService());
+ serviceCollection.AddHostedService();
+ }
+}
diff --git a/Jellyfin.Plugin.ShareLinks/Services/IShareLinkCleanupService.cs b/Jellyfin.Plugin.ShareLinks/Services/IShareLinkCleanupService.cs
new file mode 100644
index 0000000..2ac457c
--- /dev/null
+++ b/Jellyfin.Plugin.ShareLinks/Services/IShareLinkCleanupService.cs
@@ -0,0 +1,13 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Jellyfin.Plugin.ShareLinks.Services;
+
+///
+/// Cleanup seam for later workers. The initial implementation is a no-op.
+///
+public interface IShareLinkCleanupService
+{
+ /// Runs one cleanup pass.
+ Task CleanupAsync(CancellationToken cancellationToken);
+}
diff --git a/Jellyfin.Plugin.ShareLinks/Services/ItemTagService.cs b/Jellyfin.Plugin.ShareLinks/Services/ItemTagService.cs
new file mode 100644
index 0000000..6d92b96
--- /dev/null
+++ b/Jellyfin.Plugin.ShareLinks/Services/ItemTagService.cs
@@ -0,0 +1,122 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Plugin.ShareLinks.Services;
+
+/// Applies and removes temporary tags on shared items.
+public sealed class ItemTagService
+{
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILogger _logger;
+
+ /// Initializes a new instance of the class.
+ public ItemTagService(ILibraryManager libraryManager, ILogger logger)
+ {
+ _libraryManager = libraryManager;
+ _logger = logger;
+ }
+
+ /// Ensures the supplied tag is present on the item and persisted.
+ public async Task EnsureTagAsync(BaseItem item, string tag, CancellationToken cancellationToken)
+ {
+ if (item is null)
+ {
+ throw new ArgumentNullException(nameof(item));
+ }
+
+ if (string.IsNullOrWhiteSpace(tag))
+ {
+ throw new ArgumentException("Tag cannot be empty.", nameof(tag));
+ }
+
+ var tags = item.Tags?.ToList() ?? new List();
+ if (tags.Any(existing => string.Equals(existing, tag, StringComparison.OrdinalIgnoreCase)))
+ {
+ return false;
+ }
+
+ tags.Add(tag);
+ item.Tags = tags.ToArray();
+ await PersistAsync(item, cancellationToken).ConfigureAwait(false);
+ _logger.LogInformation("ShareLinks: applied temporary tag {Tag} to item {ItemId}.", tag, item.Id);
+ return true;
+ }
+
+ /// Removes the supplied tag from the item and persists the change.
+ public async Task RemoveTagAsync(BaseItem item, string tag, CancellationToken cancellationToken)
+ {
+ if (item is null)
+ {
+ throw new ArgumentNullException(nameof(item));
+ }
+
+ if (string.IsNullOrWhiteSpace(tag))
+ {
+ throw new ArgumentException("Tag cannot be empty.", nameof(tag));
+ }
+
+ var tags = item.Tags?.ToList() ?? new List();
+ var removed = tags.RemoveAll(existing => string.Equals(existing, tag, StringComparison.OrdinalIgnoreCase)) > 0;
+ if (!removed)
+ {
+ return false;
+ }
+
+ item.Tags = tags.ToArray();
+ await PersistAsync(item, cancellationToken).ConfigureAwait(false);
+ _logger.LogInformation("ShareLinks: removed temporary tag {Tag} from item {ItemId}.", tag, item.Id);
+ return true;
+ }
+
+ private async Task PersistAsync(BaseItem item, CancellationToken cancellationToken)
+ {
+ var method = _libraryManager.GetType()
+ .GetMethods(BindingFlags.Instance | BindingFlags.Public)
+ .FirstOrDefault(candidate =>
+ {
+ if (!string.Equals(candidate.Name, "UpdateItemAsync", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ var parameters = candidate.GetParameters();
+ return parameters.Length == 4
+ && typeof(BaseItem).IsAssignableFrom(parameters[0].ParameterType)
+ && typeof(BaseItem).IsAssignableFrom(parameters[1].ParameterType)
+ && parameters[3].ParameterType == typeof(CancellationToken);
+ });
+
+ if (method is null)
+ {
+ throw new MissingMethodException(_libraryManager.GetType().FullName, "UpdateItemAsync");
+ }
+
+ var parametersInfo = method.GetParameters();
+ var updateReason = parametersInfo[2].ParameterType.IsEnum
+ ? Enum.ToObject(parametersInfo[2].ParameterType, 0)
+ : 0;
+
+ var parent = item.DisplayParent ?? item;
+ var task = method.Invoke(_libraryManager, new object?[]
+ {
+ item,
+ parent,
+ updateReason,
+ cancellationToken
+ }) as Task;
+
+ if (task is null)
+ {
+ throw new InvalidOperationException("UpdateItemAsync did not return a task.");
+ }
+
+ await task.ConfigureAwait(false);
+ }
+}
diff --git a/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs b/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs
new file mode 100644
index 0000000..45b50d1
--- /dev/null
+++ b/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs
@@ -0,0 +1,514 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Reflection;
+using System.Security.Cryptography;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Plugin.ShareLinks.Configuration;
+using Jellyfin.Plugin.ShareLinks.Models;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Users;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Plugin.ShareLinks.Services;
+
+/// Creates and tears down temporary Jellyfin guest users.
+public sealed class JellyfinGuestUserService
+{
+ private readonly IUserManager _userManager;
+ private readonly ILogger _logger;
+
+ /// Initializes a new instance of the class.
+ public JellyfinGuestUserService(IUserManager userManager, ILogger logger)
+ {
+ _userManager = userManager;
+ _logger = logger;
+ }
+
+ /// Builds the temporary guest username for a share record.
+ public static string BuildGuestUsername(ShareLinkRecord record)
+ {
+ var prefix = Plugin.Instance?.Configuration.GuestUsernamePrefix ?? "share-";
+ return $"{prefix}{record.Id:N}";
+ }
+
+ /// Generates a strong random password suitable for a temporary guest user.
+ public static string GeneratePassword()
+ {
+ var bytes = new byte[32];
+ RandomNumberGenerator.Fill(bytes);
+ return Base64UrlEncode(bytes);
+ }
+
+ /// Ensures the temporary guest user exists and has the correct policy and password.
+ public async Task EnsureGuestUserAsync(ShareLinkRecord record, string password, CancellationToken cancellationToken)
+ {
+ if (record is null)
+ {
+ throw new ArgumentNullException(nameof(record));
+ }
+
+ if (string.IsNullOrWhiteSpace(password))
+ {
+ throw new ArgumentException("Password cannot be empty.", nameof(password));
+ }
+
+ var username = record.GuestUserName;
+ if (string.IsNullOrWhiteSpace(username))
+ {
+ username = BuildGuestUsername(record);
+ record.GuestUserName = username;
+ }
+
+ object? user = _userManager.GetUserByName(username);
+ if (user is null)
+ {
+ user = await InvokeUserManagerAsync
+
+", StringComparison.OrdinalIgnoreCase);
+ html = bodyIndex >= 0 ? html.Insert(bodyIndex, snippet) : html + snippet;
+
+ File.WriteAllText(path, html);
+ _logger.LogInformation("ShareLinks: injected client script into {Path}.", path);
+ }
+
+}
diff --git a/Jellyfin.Plugin.ShareLinks/Web/configPage.html b/Jellyfin.Plugin.ShareLinks/Web/configPage.html
new file mode 100644
index 0000000..f59307e
--- /dev/null
+++ b/Jellyfin.Plugin.ShareLinks/Web/configPage.html
@@ -0,0 +1,359 @@
+
+
+