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"
}