diff --git a/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs b/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs index 8249342..213ceaf 100644 --- a/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs +++ b/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs @@ -12,6 +12,7 @@ using Jellyfin.Plugin.ShareLinks.Models; using Jellyfin.Plugin.ShareLinks.Services; using Jellyfin.Plugin.ShareLinks.Storage; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -201,10 +202,10 @@ public sealed class ShareLinksController : ControllerBase return NotFound(new { error = "Item not found." }); } - if (item.IsFolder) + if (item.IsFolder && item is not Series && item is not Season) { _logger.LogWarning("ShareLinks: create rejected, item {ItemId} \"{ItemName}\" is a folder or library, not shareable media.", itemId, item.Name); - return BadRequest(new { error = "Only a movie or episode can be shared, not a folder or library. Open the title's page and try again." }); + return BadRequest(new { error = "Only a movie, episode, series or season can be shared, not a library or collection. Open the title's page and try again." }); } try diff --git a/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj b/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj index 8c3035b..23d193d 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.0.0 - 1.0.0.0 - 1.0.0.0 + 1.0.1.0 + 1.0.1.0 + 1.0.1.0 true false disable diff --git a/Jellyfin.Plugin.ShareLinks/Services/ItemTagService.cs b/Jellyfin.Plugin.ShareLinks/Services/ItemTagService.cs index 6d92b96..c636125 100644 --- a/Jellyfin.Plugin.ShareLinks/Services/ItemTagService.cs +++ b/Jellyfin.Plugin.ShareLinks/Services/ItemTagService.cs @@ -5,6 +5,7 @@ using System.Reflection; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using Microsoft.Extensions.Logging; @@ -45,7 +46,7 @@ public sealed class ItemTagService 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); + _logger.LogDebug("ShareLinks: applied temporary tag {Tag} to item {ItemId}.", tag, item.Id); return true; } @@ -71,10 +72,89 @@ public sealed class ItemTagService item.Tags = tags.ToArray(); await PersistAsync(item, cancellationToken).ConfigureAwait(false); - _logger.LogInformation("ShareLinks: removed temporary tag {Tag} from item {ItemId}.", tag, item.Id); + _logger.LogDebug("ShareLinks: removed temporary tag {Tag} from item {ItemId}.", tag, item.Id); return true; } + /// + /// Ensures the supplied tag is present on the item and, for a season or folder, + /// on the item's related tree (parent series for a season; all recursive + /// children for a folder such as a series or season) so a guest can browse the + /// whole shared branch instead of only the single node the link was created on. + /// + public async Task EnsureTagTreeAsync(BaseItem item, string tag, CancellationToken cancellationToken) + { + if (item is null) + { + throw new ArgumentNullException(nameof(item)); + } + + var targets = BuildTagTreeTargets(item); + var changed = false; + foreach (var target in targets) + { + cancellationToken.ThrowIfCancellationRequested(); + changed |= await EnsureTagAsync(target, tag, cancellationToken).ConfigureAwait(false); + } + + _logger.LogInformation( + "ShareLinks: ensured tag {Tag} across {Count} item(s) rooted at {ItemId} \"{ItemName}\".", + tag, + targets.Count, + item.Id, + item.Name); + return changed; + } + + /// + /// Removes the supplied tag from the item and, for a season or folder, from the + /// item's related tree (mirrors ). + /// + public async Task RemoveTagTreeAsync(BaseItem item, string tag, CancellationToken cancellationToken) + { + if (item is null) + { + throw new ArgumentNullException(nameof(item)); + } + + var targets = BuildTagTreeTargets(item); + var changed = false; + foreach (var target in targets) + { + cancellationToken.ThrowIfCancellationRequested(); + changed |= await RemoveTagAsync(target, tag, cancellationToken).ConfigureAwait(false); + } + + _logger.LogInformation( + "ShareLinks: removed tag {Tag} across {Count} item(s) rooted at {ItemId} \"{ItemName}\".", + tag, + targets.Count, + item.Id, + item.Name); + return changed; + } + + private static List BuildTagTreeTargets(BaseItem item) + { + var targets = new List { item }; + + if (item is Season season) + { + var series = season.Series ?? season.GetParent() as Series; + if (series is not null) + { + targets.Add(series); + } + } + + if (item is Folder folder) + { + targets.AddRange(folder.GetRecursiveChildren()); + } + + return targets; + } + private async Task PersistAsync(BaseItem item, CancellationToken cancellationToken) { var method = _libraryManager.GetType() diff --git a/Jellyfin.Plugin.ShareLinks/Services/ShareLinkCleanupService.cs b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkCleanupService.cs index 9abc3d6..0c9a0ef 100644 --- a/Jellyfin.Plugin.ShareLinks/Services/ShareLinkCleanupService.cs +++ b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkCleanupService.cs @@ -128,7 +128,7 @@ public sealed class ShareLinkCleanupService : IShareLinkCleanupService { try { - var removed = await _itemTagService.RemoveTagAsync(item, record.AllowedTag!, cancellationToken).ConfigureAwait(false); + var removed = await _itemTagService.RemoveTagTreeAsync(item, record.AllowedTag!, cancellationToken).ConfigureAwait(false); record.MetadataTouched |= removed; } catch (Exception ex) diff --git a/Jellyfin.Plugin.ShareLinks/Services/ShareLinkCreationService.cs b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkCreationService.cs index be934a1..f71f6c4 100644 --- a/Jellyfin.Plugin.ShareLinks/Services/ShareLinkCreationService.cs +++ b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkCreationService.cs @@ -65,7 +65,7 @@ public sealed class ShareLinkCreationService { if (!string.IsNullOrWhiteSpace(record.AllowedTag)) { - record.MetadataTouched = await _itemTagService.EnsureTagAsync(item, record.AllowedTag!, cancellationToken).ConfigureAwait(false); + record.MetadataTouched = await _itemTagService.EnsureTagTreeAsync(item, record.AllowedTag!, cancellationToken).ConfigureAwait(false); } record.Status = ShareLinkStatus.Active; diff --git a/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs index dfe296b..1b50166 100644 --- a/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs +++ b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs @@ -88,7 +88,7 @@ public sealed class ShareLinkRedemptionService if (!string.IsNullOrWhiteSpace(record.AllowedTag)) { - await _itemTagService.EnsureTagAsync(item, record.AllowedTag!, cancellationToken).ConfigureAwait(false); + await _itemTagService.EnsureTagTreeAsync(item, record.AllowedTag!, cancellationToken).ConfigureAwait(false); record.MetadataTouched = true; } diff --git a/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js b/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js index f4d1ffb..5b8c010 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.0-ui-modal-14'; + var clientVersion = '1.0.1-ui-1'; var allowedItemStorageKey = 'sharelinks.allowedItemId'; var guestClassName = 'sharelinks-guest'; var hiddenAttr = 'data-sharelinks-hidden'; @@ -21,7 +21,8 @@ 'moreinfo', 'mediainfo', 'editmetadata', 'editimages', 'editsubtitles', 'editlyrics', 'identify', 'refreshmetadata', 'refresh', 'playlist', 'addtoplaylist', 'addtocollection', 'instantmix', 'shuffle', 'resume', - 'copy-stream', 'copystream', 'share', 'download', 'delete' + 'copy-stream', 'copystream', 'share', 'download', 'delete', + 'edit' ]; var durationOptions = [ { label: '1 hour', hours: 1 }, @@ -282,10 +283,20 @@ // UX-only lockdown: the guest user's real access boundary is still the // server-side policy and item tags. This just keeps the web client out - // of the user's way. - if (context.allowedItemId && !isAllowedLocation(context.allowedItemId)) { + // of the user's way, while still letting the guest browse into the + // shared tree (series -> season -> episode) when the server says the + // item is visible to them. + if (!context.allowedItemId) { + return; + } + + var verdict = await checkAllowedLocation(context.allowedItemId); + if (verdict === false) { navigateToItem(context.allowedItemId); } + // verdict === true: allowed, do nothing. + // verdict === null: check still in flight or route is not item-scoped + // in a way we can verify yet; do nothing to avoid flicker. } async function getGuestContext() { @@ -695,16 +706,112 @@ return /#\/(?:details|video|playback|list|item)/i.test(location.hash || ''); } - function isAllowedLocation(allowedItemId) { + var itemVisibilityCache = {}; + var itemVisibilityInFlight = {}; + + function parseCandidateIdsFromUrl() { + var sources = [location.hash, location.search, location.href]; + var keys = ['id', 'seriesId', 'parentId', 'topParentId']; + var found = []; + + for (var k = 0; k < keys.length; k += 1) { + var pattern = new RegExp('[?&]' + keys[k] + '=([^&#]+)', 'i'); + for (var i = 0; i < sources.length; i += 1) { + var text = sources[i]; + var match = text.match(pattern); + if (match && match[1]) { + var decoded = decodeURIComponent(match[1]); + if (isItemGuid(decoded) && found.indexOf(decoded) < 0) { + found.push(decoded); + } + break; + } + } + } + + return found; + } + + /** + * Returns true/false when the verdict for the current location is already + * known, or null while it is still being resolved (or the route is not one + * we verify). A true/false verdict for a given id is cached so the mutation + * observer re-running this on every DOM tick does not spam the API. + */ + function checkAllowedLocation(allowedItemId) { var hash = location.hash || ''; if (/#\/(?:video|playback)/i.test(hash)) { return true; } - if (/#\/(?:details|item)/i.test(hash)) { - var id = parseItemIdFromUrl(); - return !!id && !!allowedItemId && id.toLowerCase() === String(allowedItemId).toLowerCase(); + + if (!/#\/(?:details|item|list)/i.test(hash)) { + return false; } - return false; + + var candidates = parseCandidateIdsFromUrl(); + var normalizedAllowed = String(allowedItemId || '').toLowerCase(); + + if (candidates.length === 0) { + // Details/list route we could not extract an id from: fall back to + // the strict same-item check rather than guessing. + return false; + } + + if (candidates.some(function (id) { return id.toLowerCase() === normalizedAllowed; })) { + return true; + } + + // None of the candidate ids is the shared item itself. Ask the server + // whether the current user (the guest) can actually fetch any of them - + // the AllowedTags policy is the real access boundary, so a successful + // fetch means the guest is allowed to be here (e.g. a season/episode + // inside a shared series or season). + return resolveServerVisibility(candidates); + } + + function resolveServerVisibility(candidates) { + var pending = false; + + for (var i = 0; i < candidates.length; i += 1) { + var id = candidates[i].toLowerCase(); + if (Object.prototype.hasOwnProperty.call(itemVisibilityCache, id)) { + if (itemVisibilityCache[id] === true) { + return true; + } + continue; + } + + pending = true; + if (!itemVisibilityInFlight[id]) { + itemVisibilityInFlight[id] = fetchItemVisibility(id); + } + } + + // No definitive "allowed" verdict yet. If at least one candidate is + // still being checked, stay neutral (no redirect) until it settles. + // Only report a definitive rejection once every candidate has a + // cached, negative verdict. + return pending ? null : false; + } + + function fetchItemVisibility(id) { + return Promise.resolve() + .then(function () { + var userId = ApiClient.getCurrentUserId(); + return ApiClient.getItem(userId, id); + }) + .then(function () { + itemVisibilityCache[id] = true; + return true; + }) + .catch(function () { + itemVisibilityCache[id] = false; + return false; + }) + .finally(function () { + delete itemVisibilityInFlight[id]; + scheduleWork(); + }); } function navigateToItem(itemId) { diff --git a/Jellyfin.Plugin.ShareLinks/meta.json b/Jellyfin.Plugin.ShareLinks/meta.json index 4ac2fed..423911a 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.0.0", + "version": "1.0.1.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-06T00:00:00.0000000Z" + "timestamp": "2026-07-08T00:00:00.0000000Z" } diff --git a/README.md b/README.md index 441ac19..463ab4c 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # ShareLinks for Jellyfin -Send someone a single movie or episode, without giving them an account, without -them seeing the rest of your library. +Send someone a single movie, episode, season or whole series, without giving +them an account, without them seeing the rest of your library. > *"here, watch this one film, the link dies tomorrow"* ShareLinks adds a **ShareLink** button (with a little share icon) to the context -menu of any movie or episode in the Jellyfin web client. Click it, pick how long -the link should live, and you get a URL you can send to anyone. When they open -it, they land straight on that one title, already signed in, and they cannot -wander off into the rest of your server. +menu of any movie, episode, series or season in the Jellyfin web client. Click +it, pick how long the link should live, and you get a URL you can send to +anyone. When they open it, they land straight on that title, already signed in, +and they cannot wander off into the rest of your server. No account for them to create, no password for you to hand out, no permanent guest user piling up. The link is temporary, the guest is temporary, and when it @@ -26,35 +26,47 @@ real user or handing over a login that sees everything. ## How it works -1. As an admin you open the context menu on a movie or episode and hit - **ShareLink**. You choose an expiry (1 hour up to 30 days) and the plugin - hands you a link, copied to your clipboard. -2. Behind the scenes the plugin tags that one item with a unique, random tag and - records the share. The raw link token is shown to you once and never stored, - only a keyed HMAC hash of it is kept. +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. +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 + guest can actually browse from the series page down into a season and an + episode, not just see a single locked node. The raw link token is shown to + you once and never stored, only a keyed HMAC hash of it is kept. 3. Whoever opens the link gets a throwaway guest user created on the spot, - restricted by that tag to the single shared item, and is signed in + restricted by that tag to the shared item and its tree, and is signed in automatically. They land on the title's page. 4. When the link expires (or you revoke it), a cleanup pass disables and deletes - the guest user and strips the temporary tag. A scheduled task and a startup - pass make sure nothing lingers if the server was off at expiry time. + the guest user and strips the temporary tag from the whole tree again. A + scheduled task and a startup pass make sure nothing lingers if the server was + off at expiry time. ## What the guest sees -Just the one title, and the ability to play it. The confinement is real and it -is enforced on the server, not only in the browser: +Just the shared title (and, for a series or season, its seasons and episodes), +and the ability to play them. The confinement is real and it is enforced on the +server, not only in the browser: -- The guest's Jellyfin policy only permits the single shared item, so every - other movie, show, library and search comes back empty from the API. Even - someone poking at the raw API cannot list your other content. +- The guest's Jellyfin policy only permits items carrying the share's tag, so + every other movie, show, library and search comes back empty from the API. + Even someone poking at the raw API cannot list your other content. - On top of that, the web client is locked down for the guest: the home, menu and search buttons are hidden, in-page links (cast, studio, genres) are - made inert, "add to playlist" is removed, and any attempt to navigate away - snaps back to the shared title. + made inert, "add to playlist" is removed, and any attempt to navigate + somewhere outside the shared tree snaps back to the shared title. Navigating + within the tree - series to season to episode - works normally. Playback works normally, including transcoding and remuxing if you allow it, and the player's back button still returns them to the title's page. +One honest caveat: if you share a series or season and new episodes get added +to it later, those episodes only pick up the tag (and become visible to the +guest) the next time the link is redeemed - not the instant they are added. For +a one-use link that has already been redeemed, that never happens, so a +one-use link is a snapshot of the tree as it existed at redemption time. + ## Managing links The plugin's dashboard page lists every share with its status, the title, a