Merge pull request #6 from Franciskid/series-season-sharing

Share whole series and seasons
Cette révision appartient à :
Francois CB
2026-07-08 01:51:15 +02:00
révisé par GitHub
révision 95813b8e42
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.Services;
using Jellyfin.Plugin.ShareLinks.Storage; using Jellyfin.Plugin.ShareLinks.Storage;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@@ -201,10 +202,10 @@ public sealed class ShareLinksController : ControllerBase
return NotFound(new { error = "Item not found." }); 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); _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 try
+3 -3
Voir le fichier
@@ -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.0.0</Version> <Version>1.0.1.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion> <AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion> <FileVersion>1.0.1.0</FileVersion>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo> <GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors> <TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<ImplicitUsings>disable</ImplicitUsings> <ImplicitUsings>disable</ImplicitUsings>
+82 -2
Voir le fichier
@@ -5,6 +5,7 @@ using System.Reflection;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -45,7 +46,7 @@ public sealed class ItemTagService
tags.Add(tag); tags.Add(tag);
item.Tags = tags.ToArray(); item.Tags = tags.ToArray();
await PersistAsync(item, cancellationToken).ConfigureAwait(false); 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; return true;
} }
@@ -71,10 +72,89 @@ public sealed class ItemTagService
item.Tags = tags.ToArray(); item.Tags = tags.ToArray();
await PersistAsync(item, cancellationToken).ConfigureAwait(false); 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; 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) private async Task PersistAsync(BaseItem item, CancellationToken cancellationToken)
{ {
var method = _libraryManager.GetType() var method = _libraryManager.GetType()
+1 -1
Voir le fichier
@@ -128,7 +128,7 @@ public sealed class ShareLinkCleanupService : IShareLinkCleanupService
{ {
try 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; record.MetadataTouched |= removed;
} }
catch (Exception ex) catch (Exception ex)
+1 -1
Voir le fichier
@@ -65,7 +65,7 @@ public sealed class ShareLinkCreationService
{ {
if (!string.IsNullOrWhiteSpace(record.AllowedTag)) 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; record.Status = ShareLinkStatus.Active;
+1 -1
Voir le fichier
@@ -88,7 +88,7 @@ public sealed class ShareLinkRedemptionService
if (!string.IsNullOrWhiteSpace(record.AllowedTag)) 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; record.MetadataTouched = true;
} }
+116 -9
Voir le fichier
@@ -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.0-ui-modal-14'; var clientVersion = '1.0.1-ui-1';
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';
@@ -21,7 +21,8 @@
'moreinfo', 'mediainfo', 'editmetadata', 'editimages', 'editsubtitles', 'moreinfo', 'mediainfo', 'editmetadata', 'editimages', 'editsubtitles',
'editlyrics', 'identify', 'refreshmetadata', 'refresh', 'playlist', 'editlyrics', 'identify', 'refreshmetadata', 'refresh', 'playlist',
'addtoplaylist', 'addtocollection', 'instantmix', 'shuffle', 'resume', 'addtoplaylist', 'addtocollection', 'instantmix', 'shuffle', 'resume',
'copy-stream', 'copystream', 'share', 'download', 'delete' 'copy-stream', 'copystream', 'share', 'download', 'delete',
'edit'
]; ];
var durationOptions = [ var durationOptions = [
{ label: '1 hour', hours: 1 }, { label: '1 hour', hours: 1 },
@@ -282,10 +283,20 @@
// UX-only lockdown: the guest user's real access boundary is still the // 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 // server-side policy and item tags. This just keeps the web client out
// of the user's way. // of the user's way, while still letting the guest browse into the
if (context.allowedItemId && !isAllowedLocation(context.allowedItemId)) { // 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); 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() { async function getGuestContext() {
@@ -695,16 +706,112 @@
return /#\/(?:details|video|playback|list|item)/i.test(location.hash || ''); 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 || ''; var hash = location.hash || '';
if (/#\/(?:video|playback)/i.test(hash)) { if (/#\/(?:video|playback)/i.test(hash)) {
return true; return true;
} }
if (/#\/(?:details|item)/i.test(hash)) {
var id = parseItemIdFromUrl(); if (!/#\/(?:details|item|list)/i.test(hash)) {
return !!id && !!allowedItemId && id.toLowerCase() === String(allowedItemId).toLowerCase(); 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) { function navigateToItem(itemId) {
+2 -2
Voir le fichier
@@ -1,12 +1,12 @@
{ {
"guid": "68540b76-ee74-436d-85ff-2abc884bbea6", "guid": "68540b76-ee74-436d-85ff-2abc884bbea6",
"name": "ShareLinks", "name": "ShareLinks",
"version": "1.0.0.0", "version": "1.0.1.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-06T00:00:00.0000000Z" "timestamp": "2026-07-08T00:00:00.0000000Z"
} }
+34 -22
Voir le fichier
@@ -1,15 +1,15 @@
# ShareLinks for Jellyfin # ShareLinks for Jellyfin
Send someone a single movie or episode, without giving them an account, without Send someone a single movie, episode, season or whole series, without giving
them seeing the rest of your library. them an account, without them seeing the rest of your library.
> *"here, watch this one film, the link dies tomorrow"* > *"here, watch this one film, the link dies tomorrow"*
ShareLinks adds a **ShareLink** button (with a little share icon) to the context 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 menu of any movie, episode, series or season in the Jellyfin web client. Click
the link should live, and you get a URL you can send to anyone. When they open it, pick how long the link should live, and you get a URL you can send to
it, they land straight on that one title, already signed in, and they cannot anyone. When they open it, they land straight on that title, already signed in,
wander off into the rest of your server. 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 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 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 ## How it works
1. As an admin you open the context menu on a movie or episode and hit 1. As an admin you open the context menu on a movie, episode, series or season
**ShareLink**. You choose an expiry (1 hour up to 30 days) and the plugin and hit **ShareLink**. You choose an expiry (1 hour up to 30 days) and the
hands you a link, copied to your clipboard. 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 2. Behind the scenes the plugin tags the shared item with a unique, random tag
records the share. The raw link token is shown to you once and never stored, and records the share. Share a series or a season and the tag is applied to
only a keyed HMAC hash of it is kept. 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, 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. automatically. They land on the title's page.
4. When the link expires (or you revoke it), a cleanup pass disables and deletes 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 the guest user and strips the temporary tag from the whole tree again. A
pass make sure nothing lingers if the server was off at expiry time. scheduled task and a startup pass make sure nothing lingers if the server was
off at expiry time.
## What the guest sees ## What the guest sees
Just the one title, and the ability to play it. The confinement is real and it Just the shared title (and, for a series or season, its seasons and episodes),
is enforced on the server, not only in the browser: 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 - The guest's Jellyfin policy only permits items carrying the share's tag, so
other movie, show, library and search comes back empty from the API. Even every other movie, show, library and search comes back empty from the API.
someone poking at the raw API cannot list your other content. 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, - 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 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 made inert, "add to playlist" is removed, and any attempt to navigate
snaps back to the shared title. 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 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. 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 ## Managing links
The plugin's dashboard page lists every share with its status, the title, a The plugin's dashboard page lists every share with its status, the title, a