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 à :
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Référencer dans un nouveau ticket
Bloquer un utilisateur