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.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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,18 +706,114 @@
|
|||||||
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) {
|
||||||
var target = '#/details?id=' + encodeURIComponent(itemId);
|
var target = '#/details?id=' + encodeURIComponent(itemId);
|
||||||
if (location.hash !== target) {
|
if (location.hash !== target) {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Référencer dans un nouveau ticket
Bloquer un utilisateur