share whole series and seasons, not just single titles

Sharing a series or season now tags the entire tree (series, seasons,
episodes) so the guest can browse and play everything inside it, and
strips it all again at cleanup. Redeeming re-tags the tree, so episodes
added after the link was created show up on the next redemption. The
guest lockdown in the web client now asks the server whether a page's
item is visible to the guest instead of hard-coding the single shared
id, so guests can navigate inside the shared tree but nowhere else.
Libraries and collections are still rejected.

Bumps the version to 1.0.1.0.
Cette révision appartient à :
Franciskid
2026-07-08 01:50:57 +02:00
Parent c637558085
révision bbb999842f
9 fichiers modifiés avec 243 ajouts et 43 suppressions
+3 -2
Voir le fichier
@@ -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
+3 -3
Voir le fichier
@@ -6,9 +6,9 @@
<LangVersion>latest</LangVersion>
<RootNamespace>Jellyfin.Plugin.ShareLinks</RootNamespace>
<AssemblyName>Jellyfin.Plugin.ShareLinks</AssemblyName>
<Version>1.0.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<Version>1.0.1.0</Version>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.0.1.0</FileVersion>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<ImplicitUsings>disable</ImplicitUsings>
+82 -2
Voir le fichier
@@ -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;
}
/// <summary>
/// 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.
/// </summary>
public async Task<bool> 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;
}
/// <summary>
/// Removes the supplied tag from the item and, for a season or folder, from the
/// item's related tree (mirrors <see cref="EnsureTagTreeAsync"/>).
/// </summary>
public async Task<bool> 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<BaseItem> BuildTagTreeTargets(BaseItem item)
{
var targets = new List<BaseItem> { 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()
+1 -1
Voir le fichier
@@ -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)
+1 -1
Voir le fichier
@@ -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;
+1 -1
Voir le fichier
@@ -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;
}
+116 -9
Voir le fichier
@@ -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) {
+2 -2
Voir le fichier
@@ -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"
}
+34 -22
Voir le fichier
@@ -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