fix(extensions): add persistent enable controls
CI / build-and-test (push) Has been cancelled

This commit is contained in:
joshpatra committed 2026-07-09 02:21:06 -04:00
1 parent 9718408272
commit 3836a91397
6 files changed
+305 -34

No files matched your search

+1 -1
View File
@@ -46,7 +46,7 @@ public class AdminUiController : ControllerBase
{
var activeBackend = _configuration.GetValue<string>("Backend:Type") ?? "Jellyfin";
var repositories = _extensionManager.GetConfiguredRepositories();
var installedExtensionCount = _extensionManager.GetActiveExtensions().Count;
var installedExtensionCount = _extensionManager.GetInstalledExtensions().Count;
var schema = new AdminUiSchemaResponse
{
+27 -2
View File
@@ -34,7 +34,7 @@ public class ExtensionController : ControllerBase
[HttpGet("installed")]
public IActionResult GetInstalledExtensions()
{
var items = _extensionManager.GetActiveExtensions()
var items = _extensionManager.GetInstalledExtensions()
.Select(e => new
{
e.Id,
@@ -42,7 +42,8 @@ public class ExtensionController : ControllerBase
e.DisplayName,
e.Description,
e.Version,
e.Types
e.Types,
e.Enabled
})
.ToList();
return Ok(items);
@@ -89,6 +90,30 @@ public class ExtensionController : ControllerBase
return NotFound(new { success = false, message = "Extension not found or failed to delete." });
}
}
[HttpPost("disable/{id}")]
public IActionResult DisableExtension(string id)
{
var success = _extensionManager.DisableExtension(id);
if (success)
{
return Ok(new { success = true, message = "Extension disabled successfully." });
}
return NotFound(new { success = false, message = "Extension not found." });
}
[HttpPost("enable/{id}")]
public async Task<IActionResult> EnableExtension(string id)
{
var success = await _extensionManager.EnableExtensionAsync(id);
if (success)
{
return Ok(new { success = true, message = "Extension enabled successfully." });
}
return NotFound(new { success = false, message = "Extension not found or failed to load." });
}
}
public class InstallRequest
+146 -15
View File
@@ -17,6 +17,8 @@ namespace allstarr.Services.Common;
public class ExtensionManager
{
private const string DisabledMarkerFile = ".disabled";
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<ExtensionManager> _logger;
private readonly IConfiguration _configuration;
@@ -48,9 +50,24 @@ public class ExtensionManager
public IReadOnlyCollection<ExtensionSandbox> GetActiveExtensions() => _activeExtensions.Values.ToList();
public IReadOnlyCollection<InstalledExtensionInfo> GetInstalledExtensions()
{
if (!Directory.Exists(_extensionsDir))
{
return [];
}
return Directory.GetDirectories(_extensionsDir)
.Select(ReadInstalledExtensionInfo)
.Where(item => item != null)
.Select(item => item!)
.OrderBy(item => item.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToList();
}
public ExtensionSandbox? GetExtension(string id)
{
return _activeExtensions.TryGetValue(id.ToLowerInvariant(), out var sandbox) ? sandbox : null;
return _activeExtensions.TryGetValue(NormalizeExtensionId(id), out var sandbox) ? sandbox : null;
}
public List<string> GetConfiguredRepositories()
@@ -121,7 +138,8 @@ public class ExtensionManager
var parsedItems = ParseStoreRegistry(json, repo);
foreach (var item in parsedItems)
{
item.IsInstalled = _activeExtensions.ContainsKey(item.Id.ToLowerInvariant());
item.IsInstalled = IsExtensionInstalled(item.Id);
item.IsEnabled = _activeExtensions.ContainsKey(item.Id.ToLowerInvariant());
catalog.Items.Add(item);
}
}
@@ -205,25 +223,59 @@ public class ExtensionManager
public bool UninstallExtension(string id)
{
var normId = id.ToLowerInvariant();
if (_activeExtensions.TryRemove(normId, out _))
_activeExtensions.TryRemove(normId, out _);
var folder = Path.Combine(_extensionsDir, normId);
if (Directory.Exists(folder))
{
var folder = Path.Combine(_extensionsDir, normId);
if (Directory.Exists(folder))
try
{
try
{
Directory.Delete(folder, true);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete extension directory {Path}", folder);
}
Directory.Delete(folder, true);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete extension directory {Path}", folder);
}
}
return false;
}
public bool DisableExtension(string id)
{
var normId = id.ToLowerInvariant();
var folder = Path.Combine(_extensionsDir, normId);
if (!Directory.Exists(folder))
{
return false;
}
_activeExtensions.TryRemove(normId, out _);
File.WriteAllText(Path.Combine(folder, DisabledMarkerFile), DateTime.UtcNow.ToString("O"));
_logger.LogInformation("Disabled extension {ExtensionId}", normId);
return true;
}
public async Task<bool> EnableExtensionAsync(string id)
{
var normId = id.ToLowerInvariant();
var folder = Path.Combine(_extensionsDir, normId);
if (!Directory.Exists(folder))
{
return false;
}
var disabledMarker = Path.Combine(folder, DisabledMarkerFile);
if (File.Exists(disabledMarker))
{
File.Delete(disabledMarker);
}
await BootExtensionAsync(folder);
return _activeExtensions.ContainsKey(normId);
}
private async Task BootInstalledExtensions()
{
try
@@ -231,6 +283,12 @@ public class ExtensionManager
var dirs = Directory.GetDirectories(_extensionsDir);
foreach (var dir in dirs)
{
if (IsExtensionDisabled(dir))
{
_logger.LogInformation("Skipping disabled extension folder {Path}", dir);
continue;
}
await BootExtensionAsync(dir);
}
}
@@ -244,6 +302,11 @@ public class ExtensionManager
{
try
{
if (IsExtensionDisabled(folderPath))
{
return;
}
var manifestPath = Path.Combine(folderPath, "manifest.json");
var indexJsPath = Path.Combine(folderPath, "index.js");
@@ -253,7 +316,7 @@ public class ExtensionManager
var indexJs = await File.ReadAllTextAsync(indexJsPath);
var sandbox = new ExtensionSandbox(folderPath, manifestJson, indexJs, _httpClientFactory, _logger);
_activeExtensions[sandbox.Id.ToLowerInvariant()] = sandbox;
_activeExtensions[NormalizeExtensionId(sandbox.Id)] = sandbox;
_logger.LogInformation("Loaded extension successfully: {DisplayName} ({Id}) v{Version}", sandbox.DisplayName, sandbox.Id, sandbox.Version);
}
catch (Exception ex)
@@ -262,6 +325,62 @@ public class ExtensionManager
}
}
private bool IsExtensionInstalled(string id)
{
var normId = id.ToLowerInvariant();
return Directory.Exists(Path.Combine(_extensionsDir, normId));
}
private static bool IsExtensionDisabled(string folderPath)
{
return File.Exists(Path.Combine(folderPath, DisabledMarkerFile));
}
private InstalledExtensionInfo? ReadInstalledExtensionInfo(string folderPath)
{
try
{
var manifestPath = Path.Combine(folderPath, "manifest.json");
if (!File.Exists(manifestPath))
{
return null;
}
var manifestJson = File.ReadAllText(manifestPath);
using var doc = JsonDocument.Parse(manifestJson);
var root = doc.RootElement;
var id = NormalizeExtensionId(ReadString(root, "id", "name"));
if (string.IsNullOrWhiteSpace(id))
{
id = Path.GetFileName(folderPath).ToLowerInvariant();
}
var active = _activeExtensions.TryGetValue(id, out var sandbox);
var displayName = sandbox?.DisplayName ?? ReadString(root, "displayName", "display_name", "title", "name");
if (string.IsNullOrWhiteSpace(displayName))
{
displayName = id;
}
var version = sandbox?.Version ?? ReadString(root, "version");
return new InstalledExtensionInfo
{
Id = id,
Name = sandbox?.Name ?? ReadString(root, "name", "id"),
DisplayName = displayName,
Description = sandbox?.Description ?? ReadString(root, "description", "summary"),
Version = string.IsNullOrWhiteSpace(version) ? "1.0.0" : version,
Types = sandbox?.Types.ToList() ?? ReadStringList(root, "types", "type", "capabilities", "capability"),
Enabled = active && !IsExtensionDisabled(folderPath)
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read installed extension manifest from {Path}", folderPath);
return null;
}
}
public static List<StoreExtensionItem> ParseStoreRegistry(string json, string repoUrl = "")
{
var items = new List<StoreExtensionItem>();
@@ -463,11 +582,23 @@ public class StoreExtensionItem
public string DownloadUrl { get; set; } = "";
public string Version { get; set; } = "";
public bool IsInstalled { get; set; }
public bool IsEnabled { get; set; }
public string RepoUrl { get; set; } = "";
public string HomepageUrl { get; set; } = "";
public List<string> Types { get; set; } = [];
}
public class InstalledExtensionInfo
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public string DisplayName { get; set; } = "";
public string Description { get; set; } = "";
public string Version { get; set; } = "";
public bool Enabled { get; set; }
public List<string> Types { get; set; } = [];
}
public class ExtensionSandbox
{
public string Id { get; }
+43 -2
View File
@@ -194,17 +194,21 @@ a {
.nav-list {
flex-direction: column;
align-items: stretch;
}
.nav-link {
display: flex;
justify-content: space-between;
align-items: center;
min-height: 40px;
width: 100%;
min-height: 48px;
border-radius: var(--radius);
color: var(--text-secondary);
text-decoration: none;
padding: 9px 10px;
padding: 11px 14px;
font-size: 0.98rem;
font-weight: 700;
}
.nav-link.active,
@@ -402,6 +406,13 @@ button.chip {
border-color: color-mix(in srgb, var(--error), transparent 58%);
}
.status-chip.disabled,
.chip.disabled {
color: var(--text-muted);
background: color-mix(in srgb, var(--text-muted), transparent 90%);
border-color: color-mix(in srgb, var(--text-muted), transparent 64%);
}
.chip.muted-chip {
opacity: 0.72;
}
@@ -629,6 +640,36 @@ tr:last-child td {
padding: 2px 7px;
}
.provider-token {
display: inline-flex;
align-items: center;
gap: 7px;
min-width: 0;
font-size: 0.84rem;
font-weight: 750;
}
.provider-token-logo {
display: inline-grid;
place-items: center;
width: 24px;
height: 24px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-card);
color: var(--text-primary);
font-size: 0.62rem;
font-weight: 900;
line-height: 1;
}
.provider-token-logo img {
display: block;
max-width: 16px;
max-height: 16px;
object-fit: contain;
}
.activity-list {
display: grid;
gap: var(--space-3);
+1 -1
View File
@@ -25,7 +25,7 @@
--space-5: 20px;
--space-6: 24px;
--radius: 8px;
--rail-width: 264px;
--rail-width: 316px;
--now-playing-height: 76px;
}
+87 -13
View File
@@ -129,6 +129,12 @@ function providerLogoUrl(provider) {
return logos.has(id) ? `/images/providers/${id}.svg` : "";
}
function providerDisplayName(providerId, providers = []) {
const provider = asArray(providers).find((item) =>
String(item?.id || item?.Id || "").toLowerCase() === String(providerId).toLowerCase());
return provider?.name || provider?.Name || providerMark({ id: providerId });
}
function isAwaitingApple2fa(value) {
const state = String(value?.state || value?.login_state || value?.account?.state || "").toLowerCase();
return state === "awaiting_2fa";
@@ -260,6 +266,10 @@ const API = {
installedExtensions: () => requestJson("/api/admin/extensions/installed", {}, "Failed to load installed extensions"),
installExtension: (item) =>
requestJson("/api/admin/extensions/install", jsonBody({ id: item.id || item.Id, downloadUrl: item.downloadUrl || item.DownloadUrl || "" }), "Failed to install extension"),
enableExtension: (id) =>
requestJson(`/api/admin/extensions/enable/${encodeURIComponent(id)}`, { method: "POST" }, "Failed to enable extension"),
disableExtension: (id) =>
requestJson(`/api/admin/extensions/disable/${encodeURIComponent(id)}`, { method: "POST" }, "Failed to disable extension"),
uninstallExtension: (id) =>
requestJson(`/api/admin/extensions/uninstall/${encodeURIComponent(id)}`, { method: "DELETE" }, "Failed to uninstall extension"),
scrobblingStatus: () => requestJson("/api/admin/scrobbling/status", {}, "Failed to load scrobbling"),
@@ -649,7 +659,7 @@ class AllstarrApp extends LitElement {
this.extensionActions = { ...this.extensionActions, [key]: "Installing" };
try {
await API.installExtension(item);
await Promise.all([this.loadInstalledExtensions(), this.loadExtensionStore()]);
await Promise.all([this.loadInstalledExtensions(), this.loadExtensionStore(), this.loadSchema()]);
this.toast("Extension installed");
} finally {
const nextActions = { ...this.extensionActions };
@@ -658,6 +668,46 @@ class AllstarrApp extends LitElement {
}
}
async setExtensionEnabled(item, enabled) {
const id = item.id || item.Id;
if (!id) {
return;
}
this.extensionActions = { ...this.extensionActions, [id]: enabled ? "Enabling" : "Disabling" };
try {
if (enabled) {
await API.enableExtension(id);
} else {
await API.disableExtension(id);
}
await Promise.all([this.loadInstalledExtensions(), this.loadExtensionStore(), this.loadSchema()]);
this.toast(`Extension ${enabled ? "enabled" : "disabled"}`);
} finally {
const nextActions = { ...this.extensionActions };
delete nextActions[id];
this.extensionActions = nextActions;
}
}
async uninstallExtension(item) {
const id = item.id || item.Id;
if (!id) {
return;
}
this.extensionActions = { ...this.extensionActions, [id]: "Uninstalling" };
try {
await API.uninstallExtension(id);
await Promise.all([this.loadInstalledExtensions(), this.loadExtensionStore(), this.loadSchema()]);
this.toast("Extension uninstalled");
} finally {
const nextActions = { ...this.extensionActions };
delete nextActions[id];
this.extensionActions = nextActions;
}
}
async runServiceAction(key, action) {
this.serviceResults = { ...this.serviceResults, [key]: { state: "running", message: "Testing..." } };
try {
@@ -1585,6 +1635,7 @@ class AllstarrApp extends LitElement {
}
renderPriorityGroups() {
const providers = asArray(this.schema?.providers);
return html`
<div class="panel">
<h3>Provider priority</h3>
@@ -1595,7 +1646,7 @@ class AllstarrApp extends LitElement {
<div class="priority-list">
${asArray(group.providers).map((provider, index) => html`
<span class="priority-item">
<span>${provider}</span>
${this.renderProviderToken(provider, providers)}
<button ?disabled=${index === 0} @click=${() => this.movePriority(group, index, -1)}>Up</button>
<button ?disabled=${index === group.providers.length - 1} @click=${() => this.movePriority(group, index, 1)}>Down</button>
</span>
@@ -1603,7 +1654,7 @@ class AllstarrApp extends LitElement {
</div>
${group.enabledEnvKey ? html`
<div class="chip-list provider-enabled-list">
${this.capabilityProviders(group.id).map((provider) => html`<span class="chip success">${provider}</span>`)}
${this.capabilityProviders(group.id).map((provider) => html`<span class="chip success">${this.renderProviderToken(provider, providers)}</span>`)}
</div>
` : nothing}
</div>
@@ -1613,6 +1664,19 @@ class AllstarrApp extends LitElement {
`;
}
renderProviderToken(providerId, providers = asArray(this.schema?.providers)) {
const label = providerDisplayName(providerId, providers);
const logoUrl = providerLogoUrl({ id: providerId, name: label });
return html`
<span class="provider-token">
<span class="provider-token-logo provider-${providerId}">
${logoUrl ? html`<img src="${logoUrl}" alt="">` : providerMark({ id: providerId, name: label }).slice(0, 2)}
</span>
<span>${label}</span>
</span>
`;
}
async movePriority(group, index, direction) {
const providers = [...group.providers];
const target = index + direction;
@@ -1650,17 +1714,25 @@ class AllstarrApp extends LitElement {
<div class="panel">
<h3>Installed extensions</h3>
<div class="activity-list">
${installed.length ? installed.map((item) => html`
<div class="activity-item">
<strong>${item.displayName || item.DisplayName || item.name || item.Name}</strong>
<span class="muted">${display(item.description || item.Description)}</span>
<div class="row-actions">
${asArray(item.types || item.Types).map((type) => html`<span class="chip success">${titleCase(type)}</span>`)}
${asArray(item.capabilities || item.Capabilities).map((type) => html`<span class="chip">${titleCase(type)}</span>`)}
<button class="danger" @click=${async () => { await API.uninstallExtension(item.id || item.Id); await this.loadInstalledExtensions(); this.toast("Extension uninstalled"); }}>Uninstall</button>
${installed.length ? installed.map((item) => {
const id = item.id || item.Id;
const action = this.extensionActions[id];
const enabled = item.enabled ?? item.Enabled ?? true;
return html`
<div class="activity-item">
<strong>${item.displayName || item.DisplayName || item.name || item.Name}</strong>
<span class="muted">${display(item.description || item.Description)}</span>
<div class="row-actions">
<span class="status-chip ${enabled ? "configured" : "disabled"}">${enabled ? "Enabled" : "Disabled"}</span>
${asArray(item.types || item.Types).map((type) => html`<span class="chip success">${titleCase(type)}</span>`)}
${asArray(item.capabilities || item.Capabilities).map((type) => html`<span class="chip">${titleCase(type)}</span>`)}
<button ?disabled=${Boolean(action)} @click=${() => this.setExtensionEnabled(item, !enabled)}>${enabled ? "Disable" : "Enable"}</button>
<button class="danger" ?disabled=${Boolean(action)} @click=${() => this.uninstallExtension(item)}>Uninstall</button>
</div>
${action ? html`<div class="progress indeterminate"><span></span></div>` : nothing}
</div>
</div>
`) : html`<div class="empty">No extensions installed.</div>`}
`;
}) : html`<div class="empty">No extensions installed.</div>`}
</div>
</div>
<div class="panel">
@@ -1670,12 +1742,14 @@ class AllstarrApp extends LitElement {
const key = item.id || item.Id || item.displayName || item.DisplayName;
const action = this.extensionActions[key];
const installedItem = Boolean(item.isInstalled || item.IsInstalled);
const enabled = item.isEnabled ?? item.IsEnabled ?? false;
return html`
<div class="activity-item">
<strong>${item.displayName || item.DisplayName}</strong>
<span class="muted">${display(item.description || item.Description)}</span>
<div class="row-actions">
<span class="chip">${display(item.version || item.Version)}</span>
${installedItem ? html`<span class="status-chip ${enabled ? "configured" : "disabled"}">${enabled ? "Enabled" : "Disabled"}</span>` : nothing}
<button class="primary" ?disabled=${installedItem || Boolean(action)} @click=${() => this.installExtension(item)}>
${installedItem ? "Installed" : action || "Install"}
</button>