add a button to clear out finished share links
The dashboard listed every share ever made, including ones revoked or expired months ago, and nothing ever pruned them. There is now a "Clean up finished links" button next to Refresh that runs a normal cleanup pass and then drops the records that are done with, reporting how many went. Only revoked, expired and failed records are removed. A link that can still be used is left alone, including a spent one-use link whose guest is still watching until it expires, since that record is Redeemed rather than finished. Folded into 1.0.3.0 rather than shipped as 1.0.3.1: the ceiling fix and this are going out as one replacement of that version.
Cette révision appartient à :
@@ -269,6 +269,21 @@ public sealed class ShareLinksController : ControllerBase
|
|||||||
return Ok(ToDto(record));
|
return Ok(ToDto(record));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes revoked, expired and failed share links from the store.</summary>
|
||||||
|
[HttpPost("Admin/Cleanup")]
|
||||||
|
[Authorize(AuthenticationSchemes = "CustomAuthentication")]
|
||||||
|
public async Task<ActionResult> Cleanup(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
SetNoStoreHeaders();
|
||||||
|
if (!User.IsInRole("Administrator"))
|
||||||
|
{
|
||||||
|
return Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
var removed = await _cleanupService.PurgeFinishedAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return Ok(new { removed });
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Returns the guest session state for the current authenticated user.</summary>
|
/// <summary>Returns the guest session state for the current authenticated user.</summary>
|
||||||
[HttpGet("GuestState")]
|
[HttpGet("GuestState")]
|
||||||
[Authorize(AuthenticationSchemes = "CustomAuthentication")]
|
[Authorize(AuthenticationSchemes = "CustomAuthentication")]
|
||||||
|
|||||||
@@ -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.3.1</Version>
|
<Version>1.0.3.0</Version>
|
||||||
<AssemblyVersion>1.0.3.1</AssemblyVersion>
|
<AssemblyVersion>1.0.3.0</AssemblyVersion>
|
||||||
<FileVersion>1.0.3.1</FileVersion>
|
<FileVersion>1.0.3.0</FileVersion>
|
||||||
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
|
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
|
||||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||||
<ImplicitUsings>disable</ImplicitUsings>
|
<ImplicitUsings>disable</ImplicitUsings>
|
||||||
|
|||||||
@@ -75,6 +75,40 @@ public sealed class ShareLinkCleanupService : IShareLinkCleanupService
|
|||||||
await CleanupRecordInternalAsync(record, records, force, cancellationToken).ConfigureAwait(false);
|
await CleanupRecordInternalAsync(record, records, force, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs a normal cleanup pass and then drops every record that is finished
|
||||||
|
/// with, so the admin list does not grow forever. A link that can still be
|
||||||
|
/// used is never removed, and neither is one whose guest is still watching.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> PurgeFinishedAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Teardown first: this expires anything past its date and removes the guest
|
||||||
|
// account and tags, so nothing is dropped from the store while it still has
|
||||||
|
// state hanging off it.
|
||||||
|
await CleanupAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var records = await _store.ListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var removed = 0;
|
||||||
|
foreach (var record in records)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (record.Status is not (ShareLinkStatus.Expired or ShareLinkStatus.Revoked or ShareLinkStatus.Failed))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _store.DeleteAsync(record.Id, cancellationToken).ConfigureAwait(false);
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("ShareLinks: purged {Count} finished share-link record(s).", removed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<ShareLinkRecord> CleanupRecordInternalAsync(
|
private async Task<ShareLinkRecord> CleanupRecordInternalAsync(
|
||||||
ShareLinkRecord record,
|
ShareLinkRecord record,
|
||||||
IReadOnlyList<ShareLinkRecord> allRecords,
|
IReadOnlyList<ShareLinkRecord> allRecords,
|
||||||
|
|||||||
@@ -173,6 +173,9 @@
|
|||||||
<button is="emby-button" type="button" id="RefreshLinks" class="raised">
|
<button is="emby-button" type="button" id="RefreshLinks" class="raised">
|
||||||
<span>Refresh</span>
|
<span>Refresh</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button is="emby-button" type="button" id="CleanupLinks" class="raised">
|
||||||
|
<span>Clean up finished links</span>
|
||||||
|
</button>
|
||||||
<span id="LinksStatus" class="sl-muted sl-list-status">Loading…</span>
|
<span id="LinksStatus" class="sl-muted sl-list-status">Loading…</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="overflow-x:auto; margin-top:0.5rem;">
|
<div style="overflow-x:auto; margin-top:0.5rem;">
|
||||||
@@ -426,6 +429,30 @@
|
|||||||
document.querySelector('#RefreshLinks').addEventListener('click', function () {
|
document.querySelector('#RefreshLinks').addEventListener('click', function () {
|
||||||
loadLinks();
|
loadLinks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelector('#CleanupLinks').addEventListener('click', function () {
|
||||||
|
var button = page.querySelector('#CleanupLinks');
|
||||||
|
var status = page.querySelector('#LinksStatus');
|
||||||
|
button.disabled = true;
|
||||||
|
status.textContent = 'Cleaning up…';
|
||||||
|
ApiClient.ajax({
|
||||||
|
type: 'POST',
|
||||||
|
url: ApiClient.getUrl('ShareLinks/Admin/Cleanup'),
|
||||||
|
dataType: 'json'
|
||||||
|
}).then(function (result) {
|
||||||
|
var removed = result && (result.removed !== undefined ? result.removed : result.Removed);
|
||||||
|
removed = parseInt(removed, 10) || 0;
|
||||||
|
return loadLinks().then(function () {
|
||||||
|
page.querySelector('#LinksStatus').textContent = removed === 1
|
||||||
|
? '1 finished link removed.'
|
||||||
|
: removed + ' finished links removed.';
|
||||||
|
});
|
||||||
|
}).catch(function () {
|
||||||
|
status.textContent = 'Cleanup failed.';
|
||||||
|
}).finally(function () {
|
||||||
|
button.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"guid": "68540b76-ee74-436d-85ff-2abc884bbea6",
|
"guid": "68540b76-ee74-436d-85ff-2abc884bbea6",
|
||||||
"name": "ShareLinks",
|
"name": "ShareLinks",
|
||||||
"version": "1.0.3.1",
|
"version": "1.0.3.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-26T20:00:00.0000000Z"
|
"timestamp": "2026-07-26T20:15:00.0000000Z"
|
||||||
}
|
}
|
||||||
|
|||||||
Référencer dans un nouveau ticket
Bloquer un utilisateur