mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
feat: implement native support for SpotiFLAC .spotiflac-ext extensions via Jint JavaScript engine sandbox and repository registry store UI
This commit is contained in:
8 files changed
+1201
-20
No files matched your search
@@ -0,0 +1,88 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using allstarr.Filters;
|
||||
using allstarr.Services.Common;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin/extensions")]
|
||||
[ServiceFilter(typeof(AdminPortFilter))]
|
||||
public class ExtensionController : ControllerBase
|
||||
{
|
||||
private readonly ExtensionManager _extensionManager;
|
||||
private readonly ILogger<ExtensionController> _logger;
|
||||
|
||||
public ExtensionController(ExtensionManager extensionManager, ILogger<ExtensionController> logger)
|
||||
{
|
||||
_extensionManager = extensionManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("repos")]
|
||||
public IActionResult GetRepositories()
|
||||
{
|
||||
return Ok(_extensionManager.GetConfiguredRepositories());
|
||||
}
|
||||
|
||||
[HttpGet("store")]
|
||||
public async Task<IActionResult> GetStoreExtensions(CancellationToken cancellationToken)
|
||||
{
|
||||
var items = await _extensionManager.FetchStoreExtensionsAsync(cancellationToken);
|
||||
return Ok(items);
|
||||
}
|
||||
|
||||
[HttpGet("installed")]
|
||||
public IActionResult GetInstalledExtensions()
|
||||
{
|
||||
var items = _extensionManager.GetActiveExtensions()
|
||||
.Select(e => new
|
||||
{
|
||||
e.Id,
|
||||
e.Name,
|
||||
e.DisplayName,
|
||||
e.Description,
|
||||
e.Version,
|
||||
e.Types
|
||||
})
|
||||
.ToList();
|
||||
return Ok(items);
|
||||
}
|
||||
|
||||
[HttpPost("install")]
|
||||
public async Task<IActionResult> InstallExtension([FromBody] InstallRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.DownloadUrl))
|
||||
{
|
||||
return BadRequest(new { success = false, message = "Download URL is required." });
|
||||
}
|
||||
|
||||
var success = await _extensionManager.InstallExtensionAsync(request.DownloadUrl, cancellationToken);
|
||||
if (success)
|
||||
{
|
||||
return Ok(new { success = true, message = "Extension installed and loaded successfully." });
|
||||
}
|
||||
else
|
||||
{
|
||||
return StatusCode(500, new { success = false, message = "Failed to install extension." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("uninstall/{id}")]
|
||||
public IActionResult UninstallExtension(string id)
|
||||
{
|
||||
var success = _extensionManager.UninstallExtension(id);
|
||||
if (success)
|
||||
{
|
||||
return Ok(new { success = true, message = "Extension uninstalled successfully." });
|
||||
}
|
||||
else
|
||||
{
|
||||
return NotFound(new { success = false, message = "Extension not found or failed to delete." });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class InstallRequest
|
||||
{
|
||||
public string DownloadUrl { get; set; } = "";
|
||||
}
|
||||
@@ -605,6 +605,7 @@ builder.Services.AddSingleton<IDownloadService>(sp =>
|
||||
squidWtfStreamingUrls));
|
||||
|
||||
// 3. Status Manager & Multi-Provider Orchestrators
|
||||
builder.Services.AddSingleton<ExtensionManager>();
|
||||
builder.Services.AddSingleton<ProviderStatusManager>();
|
||||
builder.Services.AddSingleton<IMusicMetadataService, MultiProviderMetadataService>();
|
||||
builder.Services.AddSingleton<IDownloadService, MultiProviderDownloadService>();
|
||||
|
||||
@@ -0,0 +1,700 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Jint;
|
||||
using Jint.Native;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Search;
|
||||
using allstarr.Models.Subsonic;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
public class ExtensionManager
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<ExtensionManager> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly string _extensionsDir;
|
||||
|
||||
private readonly ConcurrentDictionary<string, ExtensionSandbox> _activeExtensions = new();
|
||||
|
||||
public ExtensionManager(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<ExtensionManager> logger,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
_extensionsDir = Path.Combine(Directory.GetCurrentDirectory(), "extensions");
|
||||
|
||||
if (!Directory.Exists(_extensionsDir))
|
||||
{
|
||||
Directory.CreateDirectory(_extensionsDir);
|
||||
}
|
||||
|
||||
// Boot installed extensions in background
|
||||
Task.Run(BootInstalledExtensions);
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<ExtensionSandbox> GetActiveExtensions() => _activeExtensions.Values.ToList();
|
||||
|
||||
public ExtensionSandbox? GetExtension(string id)
|
||||
{
|
||||
return _activeExtensions.TryGetValue(id.ToLowerInvariant(), out var sandbox) ? sandbox : null;
|
||||
}
|
||||
|
||||
public List<string> GetConfiguredRepositories()
|
||||
{
|
||||
var repos = _configuration["EXTENSION_REPOSITORIES"] ?? "https://raw.githubusercontent.com/spotiflacapp/SpotiFLAC-Extension/main/registry.json";
|
||||
return repos.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<StoreExtensionItem>> FetchStoreExtensionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = new List<StoreExtensionItem>();
|
||||
var repos = GetConfiguredRepositories();
|
||||
|
||||
using var client = _httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
foreach (var repo in repos)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await client.GetAsync(repo, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode) continue;
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.TryGetProperty("extensions", out var exts) && exts.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var ext in exts.EnumerateArray())
|
||||
{
|
||||
var id = ext.GetProperty("id").GetString() ?? "";
|
||||
var name = ext.GetProperty("name").GetString() ?? "";
|
||||
var displayName = ext.TryGetProperty("display_name", out var dn) ? dn.GetString() ?? name : name;
|
||||
var desc = ext.TryGetProperty("description", out var ds) ? ds.GetString() ?? "" : "";
|
||||
var downloadUrl = ext.GetProperty("download_url").GetString() ?? "";
|
||||
var version = ext.TryGetProperty("version", out var v) ? v.GetString() ?? "1.0.0" : "1.0.0";
|
||||
|
||||
var isInstalled = _activeExtensions.ContainsKey(id.ToLowerInvariant());
|
||||
|
||||
items.Add(new StoreExtensionItem
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
DisplayName = displayName,
|
||||
Description = desc,
|
||||
DownloadUrl = downloadUrl,
|
||||
Version = version,
|
||||
IsInstalled = isInstalled,
|
||||
RepoUrl = repo
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch extension store registry from {Repo}", repo);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public async Task<bool> InstallExtensionAsync(string downloadUrl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Downloading extension from {Url}...", downloadUrl);
|
||||
using var client = _httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
var zipBytes = await client.GetByteArrayAsync(downloadUrl, cancellationToken);
|
||||
var tempFile = Path.GetTempFileName();
|
||||
await File.WriteAllBytesAsync(tempFile, zipBytes, cancellationToken);
|
||||
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "allstarr-ext-" + Guid.NewGuid());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
ZipFile.ExtractToDirectory(tempFile, tempDir, overwriteFiles: true);
|
||||
|
||||
var manifestPath = Path.Combine(tempDir, "manifest.json");
|
||||
var indexJsPath = Path.Combine(tempDir, "index.js");
|
||||
|
||||
if (!File.Exists(manifestPath) || !File.Exists(indexJsPath))
|
||||
{
|
||||
throw new InvalidDataException("Missing manifest.json or index.js in package.");
|
||||
}
|
||||
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken);
|
||||
using var doc = JsonDocument.Parse(manifestJson);
|
||||
var id = doc.RootElement.GetProperty("name").GetString()?.ToLowerInvariant() ?? "";
|
||||
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
throw new InvalidDataException("Invalid name in manifest.json.");
|
||||
}
|
||||
|
||||
var targetFolder = Path.Combine(_extensionsDir, id);
|
||||
if (Directory.Exists(targetFolder))
|
||||
{
|
||||
Directory.Delete(targetFolder, true);
|
||||
}
|
||||
Directory.CreateDirectory(targetFolder);
|
||||
|
||||
ZipFile.ExtractToDirectory(tempFile, targetFolder, overwriteFiles: true);
|
||||
|
||||
File.Delete(tempFile);
|
||||
Directory.Delete(tempDir, true);
|
||||
|
||||
// Load and initialize
|
||||
await BootExtensionAsync(targetFolder);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to install extension from {Url}", downloadUrl);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool UninstallExtension(string id)
|
||||
{
|
||||
var normId = id.ToLowerInvariant();
|
||||
if (_activeExtensions.TryRemove(normId, out _))
|
||||
{
|
||||
var folder = Path.Combine(_extensionsDir, normId);
|
||||
if (Directory.Exists(folder))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(folder, true);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete extension directory {Path}", folder);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task BootInstalledExtensions()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dirs = Directory.GetDirectories(_extensionsDir);
|
||||
foreach (var dir in dirs)
|
||||
{
|
||||
await BootExtensionAsync(dir);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error booting installed extensions");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BootExtensionAsync(string folderPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var manifestPath = Path.Combine(folderPath, "manifest.json");
|
||||
var indexJsPath = Path.Combine(folderPath, "index.js");
|
||||
|
||||
if (!File.Exists(manifestPath) || !File.Exists(indexJsPath)) return;
|
||||
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath);
|
||||
var indexJs = await File.ReadAllTextAsync(indexJsPath);
|
||||
|
||||
var sandbox = new ExtensionSandbox(folderPath, manifestJson, indexJs, _httpClientFactory, _logger);
|
||||
_activeExtensions[sandbox.Id.ToLowerInvariant()] = sandbox;
|
||||
_logger.LogInformation("Loaded extension successfully: {DisplayName} ({Id}) v{Version}", sandbox.DisplayName, sandbox.Id, sandbox.Version);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to boot extension in folder {Path}", folderPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class StoreExtensionItem
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public string DownloadUrl { get; set; } = "";
|
||||
public string Version { get; set; } = "";
|
||||
public bool IsInstalled { get; set; }
|
||||
public string RepoUrl { get; set; } = "";
|
||||
}
|
||||
|
||||
public class ExtensionSandbox
|
||||
{
|
||||
public string Id { get; }
|
||||
public string Name { get; }
|
||||
public string DisplayName { get; }
|
||||
public string Description { get; }
|
||||
public string Version { get; }
|
||||
public List<string> Types { get; } = new();
|
||||
|
||||
private readonly Engine _engine;
|
||||
private readonly JsValue _extensionObj;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public ExtensionSandbox(string folderPath, string manifestJson, string indexJs, IHttpClientFactory httpClientFactory, ILogger logger)
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
using var doc = JsonDocument.Parse(manifestJson);
|
||||
var root = doc.RootElement;
|
||||
Id = root.GetProperty("name").GetString() ?? "";
|
||||
Name = Id;
|
||||
DisplayName = root.TryGetProperty("displayName", out var dn) ? dn.GetString() ?? Id : Id;
|
||||
Description = root.TryGetProperty("description", out var desc) ? desc.GetString() ?? "" : "";
|
||||
Version = root.TryGetProperty("version", out var v) ? v.GetString() ?? "1.0.0" : "1.0.0";
|
||||
|
||||
if (root.TryGetProperty("type", out var typeEl) && typeEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var t in typeEl.EnumerateArray())
|
||||
{
|
||||
Types.Add(t.GetString() ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
_engine = new Engine(options =>
|
||||
{
|
||||
options.LimitRecursion(150);
|
||||
options.TimeoutInterval(TimeSpan.FromSeconds(12));
|
||||
});
|
||||
|
||||
var hostBridge = new ExtensionHostBridge(folderPath, httpClientFactory, logger);
|
||||
_engine.SetValue("host", hostBridge);
|
||||
|
||||
_engine.Execute("let _registeredExtension = null; function registerExtension(obj) { _registeredExtension = obj; }");
|
||||
_engine.Execute("const log = { info: function(...args) { host.Log('info', args.join(' ')); }, warn: function(...args) { host.Log('warn', args.join(' ')); }, error: function(...args) { host.Log('error', args.join(' ')); }, debug: function(...args) { host.Log('debug', args.join(' ')); } };");
|
||||
_engine.Execute("const storage = { get: function(key) { return host.StorageGet(key); }, set: function(key, val) { host.StorageSet(key, val); } };");
|
||||
_engine.Execute("const http = { get: function(url, headers) { return host.HttpGet(url, headers); }, post: function(url, body, headers) { return host.HttpPost(url, body, headers); } };");
|
||||
_engine.Execute("const utils = { randomUserAgent: function() { return host.RandomUserAgent(); }, hmacSHA1: function(key, data) { return host.HmacSHA1(key, data); } };");
|
||||
|
||||
_engine.Execute(indexJs);
|
||||
|
||||
_extensionObj = _engine.GetValue("_registeredExtension");
|
||||
if (_extensionObj.IsUndefined() || _extensionObj.IsNull())
|
||||
{
|
||||
throw new InvalidOperationException($"Extension {Id} did not register itself using registerExtension()");
|
||||
}
|
||||
|
||||
var initFn = _extensionObj.Get("initialize");
|
||||
if (initFn.IsCallable())
|
||||
{
|
||||
var configObj = _engine.Evaluate("({})");
|
||||
_engine.Invoke(initFn, configObj);
|
||||
}
|
||||
}
|
||||
|
||||
public SearchResult Search(string query, int limit)
|
||||
{
|
||||
var searchFn = _extensionObj.Get("customSearch");
|
||||
if (!searchFn.IsCallable())
|
||||
{
|
||||
searchFn = _extensionObj.Get("searchTracks");
|
||||
}
|
||||
|
||||
if (!searchFn.IsCallable()) return new SearchResult();
|
||||
|
||||
try
|
||||
{
|
||||
var jsResult = _engine.Invoke(searchFn, query, _engine.Evaluate($"({{ limit: {limit} }})"));
|
||||
return MapJsResultToSearchResult(jsResult);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "JS customSearch invocation failed on extension {Id}", Id);
|
||||
return new SearchResult();
|
||||
}
|
||||
}
|
||||
|
||||
public Song? GetSong(string id)
|
||||
{
|
||||
var fn = _extensionObj.Get("getTrack");
|
||||
if (!fn.IsCallable()) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var jsResult = _engine.Invoke(fn, id);
|
||||
if (!jsResult.IsObject()) return null;
|
||||
|
||||
var item = jsResult.AsObject();
|
||||
var song = new Song
|
||||
{
|
||||
Id = $"ext-{Id}-song-{item.Get("id").ToString()}",
|
||||
Title = item.Get("name").ToString(),
|
||||
Artist = ParseJsArtists(item.Get("artists")),
|
||||
Album = item.Get("album").ToString(),
|
||||
ExternalProvider = Id,
|
||||
ExternalId = item.Get("id").ToString(),
|
||||
Duration = ParseJsDuration(item.Get("duration_ms")),
|
||||
Isrc = item.Get("isrc")?.ToString() ?? "",
|
||||
Genre = item.Get("genre")?.ToString() ?? "",
|
||||
IsLocal = false
|
||||
};
|
||||
song.Artists.Add(song.Artist);
|
||||
return song;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "JS getTrack failed on extension {Id}", Id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public Album? GetAlbum(string id)
|
||||
{
|
||||
var fn = _extensionObj.Get("getAlbum");
|
||||
if (!fn.IsCallable()) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var jsResult = _engine.Invoke(fn, id);
|
||||
if (!jsResult.IsObject()) return null;
|
||||
|
||||
var item = jsResult.AsObject();
|
||||
var album = new Album
|
||||
{
|
||||
Id = $"ext-{Id}-album-{item.Get("id").ToString()}",
|
||||
Title = item.Get("name").ToString(),
|
||||
Artist = ParseJsArtists(item.Get("artists")),
|
||||
Year = ParseJsYear(item.Get("release_date")),
|
||||
ExternalProvider = Id,
|
||||
ExternalId = item.Get("id").ToString()
|
||||
};
|
||||
|
||||
var tracksVal = item.Get("tracks");
|
||||
if (tracksVal.IsArray())
|
||||
{
|
||||
var arr = tracksVal.AsArray();
|
||||
foreach (var index in arr.GetOwnPropertyKeys())
|
||||
{
|
||||
var idxStr = index.ToString();
|
||||
if (idxStr == "length" || !int.TryParse(idxStr, out _)) continue;
|
||||
|
||||
var trackVal = arr.Get(index);
|
||||
if (trackVal.IsObject())
|
||||
{
|
||||
var track = trackVal.AsObject();
|
||||
var song = new Song
|
||||
{
|
||||
Id = $"ext-{Id}-song-{track.Get("id").ToString()}",
|
||||
Title = track.Get("name").ToString(),
|
||||
Artist = ParseJsArtists(track.Get("artists") ?? item.Get("artists")),
|
||||
Album = album.Title,
|
||||
ExternalProvider = Id,
|
||||
ExternalId = track.Get("id").ToString(),
|
||||
Duration = ParseJsDuration(track.Get("duration_ms")),
|
||||
Track = track.Get("track_number")?.IsNumber() == true ? (int)track.Get("track_number").AsNumber() : 1,
|
||||
IsLocal = false
|
||||
};
|
||||
song.Artists.Add(song.Artist);
|
||||
album.Songs.Add(song);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return album;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "JS getAlbum failed on extension {Id}", Id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public Artist? GetArtist(string id)
|
||||
{
|
||||
var fn = _extensionObj.Get("getArtist");
|
||||
if (!fn.IsCallable()) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var jsResult = _engine.Invoke(fn, id);
|
||||
if (!jsResult.IsObject()) return null;
|
||||
|
||||
var item = jsResult.AsObject();
|
||||
return new Artist
|
||||
{
|
||||
Id = $"ext-{Id}-artist-{item.Get("id").ToString()}",
|
||||
Name = item.Get("name").ToString(),
|
||||
ExternalProvider = Id,
|
||||
ExternalId = item.Get("id").ToString()
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "JS getArtist failed on extension {Id}", Id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private SearchResult MapJsResultToSearchResult(JsValue jsVal)
|
||||
{
|
||||
var result = new SearchResult();
|
||||
if (jsVal.IsArray())
|
||||
{
|
||||
var arr = jsVal.AsArray();
|
||||
foreach (var index in arr.GetOwnPropertyKeys())
|
||||
{
|
||||
var idxStr = index.ToString();
|
||||
if (idxStr == "length" || !int.TryParse(idxStr, out _)) continue;
|
||||
|
||||
var itemVal = arr.Get(index);
|
||||
if (!itemVal.IsObject()) continue;
|
||||
|
||||
var item = itemVal.AsObject();
|
||||
var itemType = item.Get("item_type").ToString();
|
||||
|
||||
if (itemType == "track" || itemType == "song")
|
||||
{
|
||||
var song = new Song
|
||||
{
|
||||
Id = $"ext-{Id}-song-{item.Get("id").ToString()}",
|
||||
Title = item.Get("name").ToString(),
|
||||
Artist = ParseJsArtists(item.Get("artists")),
|
||||
Album = item.Get("album").ToString(),
|
||||
ExternalProvider = Id,
|
||||
ExternalId = item.Get("id").ToString(),
|
||||
Duration = ParseJsDuration(item.Get("duration_ms")),
|
||||
Isrc = item.Get("isrc")?.ToString() ?? "",
|
||||
Genre = item.Get("genre")?.ToString() ?? "",
|
||||
IsLocal = false
|
||||
};
|
||||
song.Artists.Add(song.Artist);
|
||||
result.Songs.Add(song);
|
||||
}
|
||||
else if (itemType == "album")
|
||||
{
|
||||
var album = new Album
|
||||
{
|
||||
Id = $"ext-{Id}-album-{item.Get("id").ToString()}",
|
||||
Title = item.Get("name").ToString(),
|
||||
Artist = ParseJsArtists(item.Get("artists")),
|
||||
Year = ParseJsYear(item.Get("release_date")),
|
||||
ExternalProvider = Id,
|
||||
ExternalId = item.Get("id").ToString()
|
||||
};
|
||||
result.Albums.Add(album);
|
||||
}
|
||||
else if (itemType == "artist")
|
||||
{
|
||||
var artist = new Artist
|
||||
{
|
||||
Id = $"ext-{Id}-artist-{item.Get("id").ToString()}",
|
||||
Name = item.Get("name").ToString(),
|
||||
ExternalProvider = Id,
|
||||
ExternalId = item.Get("id").ToString()
|
||||
};
|
||||
result.Artists.Add(artist);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private string ParseJsArtists(JsValue val)
|
||||
{
|
||||
if (val.IsArray())
|
||||
{
|
||||
var arr = val.AsArray();
|
||||
var artistsList = new List<string>();
|
||||
foreach (var key in arr.GetOwnPropertyKeys())
|
||||
{
|
||||
var idxStr = key.ToString();
|
||||
if (idxStr == "length" || !int.TryParse(idxStr, out _)) continue;
|
||||
|
||||
var artistVal = arr.Get(key);
|
||||
if (artistVal.IsString()) artistsList.Add(artistVal.AsString());
|
||||
}
|
||||
if (artistsList.Count > 0) return string.Join(", ", artistsList);
|
||||
}
|
||||
return val.ToString();
|
||||
}
|
||||
|
||||
private int? ParseJsDuration(JsValue val)
|
||||
{
|
||||
if (val.IsNumber()) return (int)(val.AsNumber() / 1000);
|
||||
if (double.TryParse(val.ToString(), out var ms)) return (int)(ms / 1000);
|
||||
return null;
|
||||
}
|
||||
|
||||
private int? ParseJsYear(JsValue val)
|
||||
{
|
||||
var str = val.ToString();
|
||||
if (string.IsNullOrEmpty(str)) return null;
|
||||
if (str.Length >= 4 && int.TryParse(str.Substring(0, 4), out var yr)) return yr;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public class ExtensionHostBridge
|
||||
{
|
||||
private readonly string _folderPath;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Dictionary<string, string> _storage = new();
|
||||
private readonly string _storageFile;
|
||||
|
||||
public ExtensionHostBridge(string folderPath, IHttpClientFactory httpClientFactory, ILogger logger)
|
||||
{
|
||||
_folderPath = folderPath;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
_storageFile = Path.Combine(folderPath, "storage.json");
|
||||
LoadStorage();
|
||||
}
|
||||
|
||||
public void Log(string level, string message)
|
||||
{
|
||||
switch (level.ToLowerInvariant())
|
||||
{
|
||||
case "error": _logger.LogError("[JS EXT] {Message}", message); break;
|
||||
case "warn": _logger.LogWarning("[JS EXT] {Message}", message); break;
|
||||
case "debug": _logger.LogDebug("[JS EXT] {Message}", message); break;
|
||||
default: _logger.LogInformation("[JS EXT] {Message}", message); break;
|
||||
}
|
||||
}
|
||||
|
||||
public string? StorageGet(string key) => _storage.TryGetValue(key, out var val) ? val : null;
|
||||
|
||||
public void StorageSet(string key, string value)
|
||||
{
|
||||
_storage[key] = value;
|
||||
SaveStorage();
|
||||
}
|
||||
|
||||
public object HttpGet(string url, object? headers) => HttpCall("GET", url, null, headers);
|
||||
|
||||
public object HttpPost(string url, string? body, object? headers) => HttpCall("POST", url, body, headers);
|
||||
|
||||
public string RandomUserAgent() => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
|
||||
public byte[] HmacSHA1(JsValue keyVal, JsValue dataVal)
|
||||
{
|
||||
byte[] key = ConvertToByteArray(keyVal);
|
||||
byte[] data = ConvertToByteArray(dataVal);
|
||||
using var hmac = new HMACSHA1(key);
|
||||
return hmac.ComputeHash(data);
|
||||
}
|
||||
|
||||
private byte[] ConvertToByteArray(JsValue val)
|
||||
{
|
||||
if (val.IsArray())
|
||||
{
|
||||
var arr = val.AsArray();
|
||||
var bytes = new List<byte>();
|
||||
foreach (var key in arr.GetOwnPropertyKeys())
|
||||
{
|
||||
var idxStr = key.ToString();
|
||||
if (idxStr == "length" || !int.TryParse(idxStr, out _)) continue;
|
||||
|
||||
var bVal = arr.Get(key);
|
||||
if (bVal.IsNumber()) bytes.Add((byte)bVal.AsNumber());
|
||||
}
|
||||
return bytes.ToArray();
|
||||
}
|
||||
if (val.IsString())
|
||||
{
|
||||
return Encoding.UTF8.GetBytes(val.AsString());
|
||||
}
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
|
||||
private object HttpCall(string method, string url, string? body, object? headersObj)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = _httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
var request = new HttpRequestMessage(new HttpMethod(method), url);
|
||||
|
||||
if (body != null)
|
||||
{
|
||||
request.Content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||
}
|
||||
|
||||
if (headersObj is Jint.Native.Object.ObjectInstance obj)
|
||||
{
|
||||
foreach (var prop in obj.GetOwnProperties())
|
||||
{
|
||||
var headerKey = prop.Key.ToString();
|
||||
var headerVal = obj.Get(prop.Key).ToString();
|
||||
|
||||
if (!string.IsNullOrEmpty(headerVal))
|
||||
{
|
||||
if (headerKey.Equals("Content-Type", StringComparison.OrdinalIgnoreCase) && request.Content != null)
|
||||
{
|
||||
request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(headerVal);
|
||||
}
|
||||
else
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(headerKey, headerVal);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using var response = client.Send(request);
|
||||
var bodyText = new StreamReader(response.Content.ReadAsStream()).ReadToEnd();
|
||||
|
||||
var respHeaders = response.Headers.ToDictionary(k => k.Key, v => (object)string.Join(", ", v.Value));
|
||||
|
||||
return new {
|
||||
statusCode = (int)response.StatusCode,
|
||||
body = bodyText,
|
||||
headers = respHeaders
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new {
|
||||
statusCode = 500,
|
||||
body = "",
|
||||
error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadStorage()
|
||||
{
|
||||
if (File.Exists(_storageFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
var txt = File.ReadAllText(_storageFile);
|
||||
var data = JsonSerializer.Deserialize<Dictionary<string, string>>(txt);
|
||||
if (data != null)
|
||||
{
|
||||
foreach (var kp in data) _storage[kp.Key] = kp.Value;
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveStorage()
|
||||
{
|
||||
try
|
||||
{
|
||||
var txt = JsonSerializer.Serialize(_storage);
|
||||
File.WriteAllText(_storageFile, txt);
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
}
|
||||
@@ -9,23 +9,25 @@ public class MultiProviderMetadataService : IMusicMetadataService
|
||||
{
|
||||
private readonly IEnumerable<IMusicMetadataService> _allServices;
|
||||
private readonly ProviderStatusManager _statusManager;
|
||||
private readonly ExtensionManager _extensionManager;
|
||||
private readonly ILogger<MultiProviderMetadataService> _logger;
|
||||
|
||||
public MultiProviderMetadataService(
|
||||
IEnumerable<IMusicMetadataService> services,
|
||||
ProviderStatusManager statusManager,
|
||||
ExtensionManager extensionManager,
|
||||
ILogger<MultiProviderMetadataService> logger)
|
||||
{
|
||||
_allServices = services.Where(s => s.GetType() != typeof(MultiProviderMetadataService)).ToList();
|
||||
_statusManager = statusManager;
|
||||
_extensionManager = extensionManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var providers = _statusManager.GetEnabledSearchProviders();
|
||||
if (providers.Count == 0) return new List<Song>();
|
||||
|
||||
|
||||
var tasks = providers.Select(async p =>
|
||||
{
|
||||
var service = GetMetadataServiceByName(p);
|
||||
@@ -39,16 +41,33 @@ public class MultiProviderMetadataService : IMusicMetadataService
|
||||
_logger.LogError(ex, "SearchSongsAsync failed for provider: {Provider}", p);
|
||||
return new List<Song>();
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
var extensions = _extensionManager.GetActiveExtensions();
|
||||
var extensionTasks = extensions.Select(async ext =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var res = await Task.Run(() => ext.Search(query, limit), cancellationToken);
|
||||
return res.Songs;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "SearchSongsAsync failed for extension: {ExtensionId}", ext.Id);
|
||||
return new List<Song>();
|
||||
}
|
||||
});
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
return InterleaveLists(results.ToList());
|
||||
var providerResults = await Task.WhenAll(tasks);
|
||||
var extensionResults = await Task.WhenAll(extensionTasks);
|
||||
|
||||
var allResultsList = providerResults.Concat(extensionResults).ToList();
|
||||
return InterleaveLists(allResultsList);
|
||||
}
|
||||
|
||||
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var providers = _statusManager.GetEnabledSearchProviders();
|
||||
if (providers.Count == 0) return new List<Album>();
|
||||
|
||||
var tasks = providers.Select(async p =>
|
||||
{
|
||||
@@ -63,16 +82,33 @@ public class MultiProviderMetadataService : IMusicMetadataService
|
||||
_logger.LogError(ex, "SearchAlbumsAsync failed for provider: {Provider}", p);
|
||||
return new List<Album>();
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
var extensions = _extensionManager.GetActiveExtensions();
|
||||
var extensionTasks = extensions.Select(async ext =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var res = await Task.Run(() => ext.Search(query, limit), cancellationToken);
|
||||
return res.Albums;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "SearchAlbumsAsync failed for extension: {ExtensionId}", ext.Id);
|
||||
return new List<Album>();
|
||||
}
|
||||
});
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
return InterleaveLists(results.ToList());
|
||||
var providerResults = await Task.WhenAll(tasks);
|
||||
var extensionResults = await Task.WhenAll(extensionTasks);
|
||||
|
||||
var allResultsList = providerResults.Concat(extensionResults).ToList();
|
||||
return InterleaveLists(allResultsList);
|
||||
}
|
||||
|
||||
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var providers = _statusManager.GetEnabledSearchProviders();
|
||||
if (providers.Count == 0) return new List<Artist>();
|
||||
|
||||
var tasks = providers.Select(async p =>
|
||||
{
|
||||
@@ -87,16 +123,33 @@ public class MultiProviderMetadataService : IMusicMetadataService
|
||||
_logger.LogError(ex, "SearchArtistsAsync failed for provider: {Provider}", p);
|
||||
return new List<Artist>();
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
var extensions = _extensionManager.GetActiveExtensions();
|
||||
var extensionTasks = extensions.Select(async ext =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var res = await Task.Run(() => ext.Search(query, limit), cancellationToken);
|
||||
return res.Artists;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "SearchArtistsAsync failed for extension: {ExtensionId}", ext.Id);
|
||||
return new List<Artist>();
|
||||
}
|
||||
});
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
return InterleaveLists(results.ToList());
|
||||
var providerResults = await Task.WhenAll(tasks);
|
||||
var extensionResults = await Task.WhenAll(extensionTasks);
|
||||
|
||||
var allResultsList = providerResults.Concat(extensionResults).ToList();
|
||||
return InterleaveLists(allResultsList);
|
||||
}
|
||||
|
||||
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var providers = _statusManager.GetEnabledSearchProviders();
|
||||
if (providers.Count == 0) return new SearchResult();
|
||||
|
||||
var tasks = providers.Select(async p =>
|
||||
{
|
||||
@@ -111,21 +164,53 @@ public class MultiProviderMetadataService : IMusicMetadataService
|
||||
_logger.LogError(ex, "SearchAllAsync failed for provider: {Provider}", p);
|
||||
return null;
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
var extensions = _extensionManager.GetActiveExtensions();
|
||||
var extensionTasks = extensions.Select(async ext =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return await Task.Run(() => ext.Search(query, songLimit), cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "SearchAllAsync failed for extension: {ExtensionId}", ext.Id);
|
||||
return new SearchResult();
|
||||
}
|
||||
});
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
var validResults = results.Where(r => r != null).ToList();
|
||||
var providerResults = await Task.WhenAll(tasks);
|
||||
var extensionResults = await Task.WhenAll(extensionTasks);
|
||||
|
||||
var validProviderResults = providerResults.Where(r => r != null).ToList();
|
||||
var validExtensionResults = extensionResults.Where(r => r != null).ToList();
|
||||
|
||||
var allSongsLists = validProviderResults.Select(r => r!.Songs)
|
||||
.Concat(validExtensionResults.Select(r => r.Songs)).ToList();
|
||||
|
||||
var allAlbumsLists = validProviderResults.Select(r => r!.Albums)
|
||||
.Concat(validExtensionResults.Select(r => r.Albums)).ToList();
|
||||
|
||||
var allArtistsLists = validProviderResults.Select(r => r!.Artists)
|
||||
.Concat(validExtensionResults.Select(r => r.Artists)).ToList();
|
||||
|
||||
return new SearchResult
|
||||
{
|
||||
Songs = InterleaveLists(validResults.Select(r => r!.Songs).ToList()),
|
||||
Albums = InterleaveLists(validResults.Select(r => r!.Albums).ToList()),
|
||||
Artists = InterleaveLists(validResults.Select(r => r!.Artists).ToList())
|
||||
Songs = InterleaveLists(allSongsLists),
|
||||
Albums = InterleaveLists(allAlbumsLists),
|
||||
Artists = InterleaveLists(allArtistsLists)
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<Song?> GetSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ext = _extensionManager.GetExtension(externalProvider);
|
||||
if (ext != null)
|
||||
{
|
||||
return await Task.Run(() => ext.GetSong(externalId), cancellationToken);
|
||||
}
|
||||
|
||||
var service = GetMetadataServiceByName(externalProvider);
|
||||
if (service == null) return null;
|
||||
return await service.GetSongAsync(externalProvider, externalId, cancellationToken);
|
||||
@@ -133,10 +218,8 @@ public class MultiProviderMetadataService : IMusicMetadataService
|
||||
|
||||
public async Task<Song?> FindSongByIsrcAsync(string isrc, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Try searching in parallel across all enabled search providers to find the first exact match
|
||||
var providers = _statusManager.GetEnabledSearchProviders();
|
||||
if (providers.Count == 0) return null;
|
||||
|
||||
|
||||
var tasks = providers.Select(async p =>
|
||||
{
|
||||
var service = GetMetadataServiceByName(p);
|
||||
@@ -149,14 +232,36 @@ public class MultiProviderMetadataService : IMusicMetadataService
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
var extensions = _extensionManager.GetActiveExtensions();
|
||||
var extensionTasks = extensions.Select(async ext =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var res = await Task.Run(() => ext.Search($"isrc:{isrc}", 1), cancellationToken);
|
||||
return res.Songs.FirstOrDefault();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
return results.FirstOrDefault(s => s != null);
|
||||
var extResults = await Task.WhenAll(extensionTasks);
|
||||
|
||||
return results.FirstOrDefault(s => s != null) ?? extResults.FirstOrDefault(s => s != null);
|
||||
}
|
||||
|
||||
public async Task<Album?> GetAlbumAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ext = _extensionManager.GetExtension(externalProvider);
|
||||
if (ext != null)
|
||||
{
|
||||
return await Task.Run(() => ext.GetAlbum(externalId), cancellationToken);
|
||||
}
|
||||
|
||||
var service = GetMetadataServiceByName(externalProvider);
|
||||
if (service == null) return null;
|
||||
return await service.GetAlbumAsync(externalProvider, externalId, cancellationToken);
|
||||
@@ -164,6 +269,12 @@ public class MultiProviderMetadataService : IMusicMetadataService
|
||||
|
||||
public async Task<Artist?> GetArtistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ext = _extensionManager.GetExtension(externalProvider);
|
||||
if (ext != null)
|
||||
{
|
||||
return await Task.Run(() => ext.GetArtist(externalId), cancellationToken);
|
||||
}
|
||||
|
||||
var service = GetMetadataServiceByName(externalProvider);
|
||||
if (service == null) return null;
|
||||
return await service.GetArtistAsync(externalProvider, externalId, cancellationToken);
|
||||
@@ -171,6 +282,9 @@ public class MultiProviderMetadataService : IMusicMetadataService
|
||||
|
||||
public async Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ext = _extensionManager.GetExtension(externalProvider);
|
||||
if (ext != null) return new List<Album>(); // Extensions don't have separate artist-albums endpoint usually
|
||||
|
||||
var service = GetMetadataServiceByName(externalProvider);
|
||||
if (service == null) return new List<Album>();
|
||||
return await service.GetArtistAlbumsAsync(externalProvider, externalId, cancellationToken);
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Cronos" Version="0.11.1" />
|
||||
<PackageReference Include="Jint" Version="4.11.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" />
|
||||
<PackageReference Include="Otp.NET" Version="1.4.1" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
<div class="tab" data-tab="kept">Kept Downloads</div>
|
||||
<div class="tab" data-tab="services">Services</div>
|
||||
<div class="tab" data-tab="config">Configuration</div>
|
||||
<div class="tab" data-tab="extensions">Extensions</div>
|
||||
<div class="tab" data-tab="report-issues">Report Issues</div>
|
||||
<div class="tab" data-tab="endpoints">API Analytics</div>
|
||||
</div>
|
||||
@@ -1139,6 +1140,54 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extensions Tab -->
|
||||
<div class="tab-content" id="tab-extensions">
|
||||
<input type="hidden" id="config-extension-repositories" data-env-key="EXTENSION_REPOSITORIES" class="config-input">
|
||||
<div class="card">
|
||||
<h2>Community Extensions (.spotiflac-ext)</h2>
|
||||
<div class="description-text" style="color: var(--text-secondary); margin-bottom: 16px; font-size: 0.9rem;">
|
||||
Natively load and run community-created SpotiFLAC extensions in a secure sandbox! These plugins extend Allstarr's search, lyrics, and metadata capabilities without requiring premium credentials.
|
||||
</div>
|
||||
<div style="font-size: 0.85rem; color: var(--text-secondary); background: rgba(255,255,255,0.05); padding: 12px; border-radius: 6px; margin-bottom: 20px; border-left: 3px solid var(--accent-color);">
|
||||
💡 <strong>Where to find extensions:</strong> You can enter any SpotiFLAC Extension Store registry link (such as the official repository <code>https://raw.githubusercontent.com/spotiflacapp/SpotiFLAC-Extension/main/registry.json</code>) to populate the store below.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Repository Configuration Card -->
|
||||
<div class="card">
|
||||
<h3>Extension Registries</h3>
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
|
||||
<input type="text" id="new-repo-url" placeholder="https://raw.githubusercontent.com/.../registry.json" style="flex: 1; padding: 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.15); background: rgba(0,0,0,0.2); color: #fff;">
|
||||
<button class="primary" onclick="addExtensionRepo()" style="padding: 10px 16px;">Add Registry</button>
|
||||
</div>
|
||||
<ul id="configured-repos-list" style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px;">
|
||||
<!-- Configured repos will be listed here -->
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Extension Store Card -->
|
||||
<div class="card">
|
||||
<h3>Extension Store</h3>
|
||||
<div class="description-text" style="color: var(--text-secondary); margin-bottom: 16px; font-size: 0.85rem;">
|
||||
Browse and install community plugins from your registered registries.
|
||||
</div>
|
||||
<div id="store-extensions-list" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px;">
|
||||
<!-- Extensions from store will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Installed Extensions Card -->
|
||||
<div class="card">
|
||||
<h3>Installed Extensions</h3>
|
||||
<div class="description-text" style="color: var(--text-secondary); margin-bottom: 16px; font-size: 0.85rem;">
|
||||
Currently active and running JavaScript sandboxes.
|
||||
</div>
|
||||
<div id="installed-extensions-list" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px;">
|
||||
<!-- Installed extensions will be listed here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Report Issues Tab -->
|
||||
<div class="tab-content" id="tab-report-issues">
|
||||
<div class="card">
|
||||
|
||||
@@ -84,6 +84,10 @@ window.switchTab = function (tabName) {
|
||||
window.fetchDownloads();
|
||||
}
|
||||
|
||||
if (tabName === "extensions" && typeof window.loadExtensionsTab === "function") {
|
||||
window.loadExtensionsTab();
|
||||
}
|
||||
|
||||
if (
|
||||
tabName === "song-migration" &&
|
||||
typeof window.fetchSongMigration === "function"
|
||||
|
||||
@@ -2225,3 +2225,227 @@ window.testProviderConnection = async function(provider) {
|
||||
}
|
||||
};
|
||||
|
||||
// --- Community Extensions JavaScript API Loader ---
|
||||
window.loadExtensionsTab = async function() {
|
||||
await Promise.all([
|
||||
loadConfiguredRepos(),
|
||||
loadInstalledExtensions(),
|
||||
loadStoreExtensions()
|
||||
]);
|
||||
};
|
||||
|
||||
async function loadConfiguredRepos() {
|
||||
try {
|
||||
const res = await fetch("/api/admin/extensions/repos");
|
||||
if (!res.ok) return;
|
||||
const repos = await res.json();
|
||||
|
||||
// Update hidden input
|
||||
setInputValue("config-extension-repositories", repos.join(","));
|
||||
|
||||
const list = document.getElementById("configured-repos-list");
|
||||
if (list) {
|
||||
list.innerHTML = "";
|
||||
if (repos.length === 0) {
|
||||
list.innerHTML = `<li style="color: var(--text-secondary); font-size: 0.9rem; font-style: italic; padding: 4px 0;">No registries configured.</li>`;
|
||||
} else {
|
||||
repos.forEach(url => {
|
||||
list.innerHTML += `
|
||||
<li style="display: flex; justify-content: space-between; align-items: center; background: rgba(255,255,255,0.03); padding: 8px 12px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.05);">
|
||||
<span style="font-family: monospace; font-size: 0.85rem; word-break: break-all; color: var(--text-secondary);">${url}</span>
|
||||
<button class="btn btn-sm danger" onclick="removeExtensionRepo('${url}')" style="padding: 4px 8px; font-size: 0.8rem; margin-left: 12px;">Remove</button>
|
||||
</li>
|
||||
`;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load repos:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInstalledExtensions() {
|
||||
try {
|
||||
const res = await fetch("/api/admin/extensions/installed");
|
||||
if (!res.ok) return;
|
||||
const items = await res.json();
|
||||
|
||||
const list = document.getElementById("installed-extensions-list");
|
||||
if (list) {
|
||||
list.innerHTML = "";
|
||||
if (items.length === 0) {
|
||||
list.innerHTML = `
|
||||
<div style="grid-column: 1 / -1; text-align: center; color: var(--text-secondary); font-style: italic; padding: 20px;">
|
||||
No extensions installed. Browse the store below to install extensions.
|
||||
</div>`;
|
||||
} else {
|
||||
items.forEach(ext => {
|
||||
const typeLabels = ext.types.map(t => `<span class="badge" style="background: var(--accent-color); font-size: 0.75rem; padding: 2px 6px; border-radius: 4px; color: #fff;">${t}</span>`).join(" ");
|
||||
list.innerHTML += `
|
||||
<div class="card" style="margin: 0; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); padding: 16px; border-radius: 8px; display: flex; flex-direction: column; justify-content: space-between; gap: 12px;">
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<h4 style="margin: 0; font-size: 1.1rem; color: #fff;">${ext.displayName}</h4>
|
||||
<span style="font-size: 0.8rem; color: var(--text-secondary); font-weight: 500;">v${ext.version}</span>
|
||||
</div>
|
||||
<div style="margin-top: 6px; display: flex; gap: 4px; flex-wrap: wrap;">
|
||||
${typeLabels}
|
||||
</div>
|
||||
<p style="font-size: 0.85rem; color: var(--text-secondary); margin: 10px 0 0 0; line-height: 1.4;">${ext.description}</p>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: auto; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 12px;">
|
||||
<button class="btn btn-sm danger" onclick="uninstallExtension('${ext.id}')" style="font-size: 0.8rem; padding: 4px 10px;">Uninstall</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load installed extensions:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStoreExtensions() {
|
||||
try {
|
||||
const res = await fetch("/api/admin/extensions/store");
|
||||
if (!res.ok) return;
|
||||
const items = await res.json();
|
||||
|
||||
const list = document.getElementById("store-extensions-list");
|
||||
if (list) {
|
||||
list.innerHTML = "";
|
||||
if (items.length === 0) {
|
||||
list.innerHTML = `
|
||||
<div style="grid-column: 1 / -1; text-align: center; color: var(--text-secondary); font-style: italic; padding: 20px;">
|
||||
No extensions available. Add an extension registry repository above.
|
||||
</div>`;
|
||||
} else {
|
||||
items.forEach(ext => {
|
||||
const actionBtn = ext.isInstalled
|
||||
? `<button class="btn btn-sm" disabled style="background: rgba(255,255,255,0.08); color: var(--text-secondary); font-size: 0.8rem; padding: 4px 10px;">Installed</button>`
|
||||
: `<button class="btn btn-sm primary" onclick="installExtension('${ext.downloadUrl}')" style="font-size: 0.8rem; padding: 4px 10px;">Install</button>`;
|
||||
|
||||
list.innerHTML += `
|
||||
<div class="card" style="margin: 0; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); padding: 16px; border-radius: 8px; display: flex; flex-direction: column; justify-content: space-between; gap: 12px;">
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<h4 style="margin: 0; font-size: 1.1rem; color: #fff;">${ext.displayName}</h4>
|
||||
<span style="font-size: 0.8rem; color: var(--text-secondary); font-weight: 500;">v${ext.version}</span>
|
||||
</div>
|
||||
<p style="font-size: 0.85rem; color: var(--text-secondary); margin: 8px 0 0 0; line-height: 1.4;">${ext.description}</p>
|
||||
<div style="font-size: 0.75rem; color: var(--text-secondary); font-family: monospace; margin-top: 8px; word-break: break-all; opacity: 0.6;">
|
||||
Registry: ${new URL(ext.repoUrl).hostname}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: auto; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 12px;">
|
||||
${actionBtn}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load store extensions:", err);
|
||||
}
|
||||
}
|
||||
|
||||
window.addExtensionRepo = async function() {
|
||||
const input = document.getElementById("new-repo-url");
|
||||
if (!input) return;
|
||||
const url = input.value.trim();
|
||||
if (!url) {
|
||||
showToast("Please enter a registry URL", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentVal = document.getElementById("config-extension-repositories")?.value || "";
|
||||
const repos = currentVal.split(",").map(r => r.trim()).filter(Boolean);
|
||||
|
||||
if (repos.includes(url)) {
|
||||
showToast("Registry is already added", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
repos.push(url);
|
||||
setInputValue("config-extension-repositories", repos.join(","));
|
||||
|
||||
input.value = "";
|
||||
showToast("Adding registry...", "info");
|
||||
|
||||
try {
|
||||
await saveAllSettings("tab-extensions", { quiet: true });
|
||||
showToast("Registry added successfully. Reloading store...", "success");
|
||||
await window.loadExtensionsTab();
|
||||
} catch (e) {
|
||||
showToast("Failed to save registry", "error");
|
||||
}
|
||||
};
|
||||
|
||||
window.removeExtensionRepo = async function(url) {
|
||||
if (!confirm("Are you sure you want to remove this extension registry? Any extensions installed from it will remain installed, but they won't receive updates.")) return;
|
||||
|
||||
const currentVal = document.getElementById("config-extension-repositories")?.value || "";
|
||||
const repos = currentVal.split(",").map(r => r.trim()).filter(Boolean);
|
||||
|
||||
const idx = repos.indexOf(url);
|
||||
if (idx > -1) {
|
||||
repos.splice(idx, 1);
|
||||
setInputValue("config-extension-repositories", repos.join(","));
|
||||
}
|
||||
|
||||
showToast("Removing registry...", "info");
|
||||
|
||||
try {
|
||||
await saveAllSettings("tab-extensions", { quiet: true });
|
||||
showToast("Registry removed successfully", "success");
|
||||
await window.loadExtensionsTab();
|
||||
} catch (e) {
|
||||
showToast("Failed to save configuration", "error");
|
||||
}
|
||||
};
|
||||
|
||||
window.installExtension = async function(downloadUrl) {
|
||||
showToast("Installing extension... This may take a few seconds.", "info");
|
||||
try {
|
||||
const res = await fetch("/api/admin/extensions/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ downloadUrl })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
showToast("Extension installed and loaded!", "success");
|
||||
await window.loadExtensionsTab();
|
||||
window.showRestartBanner();
|
||||
} else {
|
||||
showToast(data.message || "Installation failed", "error");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Installation error:", error);
|
||||
showToast("Network error during installation", "error");
|
||||
}
|
||||
};
|
||||
|
||||
window.uninstallExtension = async function(id) {
|
||||
if (!confirm(`Are you sure you want to uninstall extension "${id}"?`)) return;
|
||||
|
||||
showToast("Uninstalling extension...", "info");
|
||||
try {
|
||||
const res = await fetch(`/api/admin/extensions/uninstall/${id}`, { method: "DELETE" });
|
||||
const data = await res.ok ? await res.json() : null;
|
||||
if (res.ok && data && data.success) {
|
||||
showToast("Extension uninstalled successfully", "success");
|
||||
await window.loadExtensionsTab();
|
||||
window.showRestartBanner();
|
||||
} else {
|
||||
showToast("Failed to uninstall extension", "error");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Uninstallation error:", error);
|
||||
showToast("Network error during uninstallation", "error");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user