From f27bf2c32870e90bd012c16a813d9f0cff194d61 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Tue, 7 Jul 2026 19:56:47 -0400 Subject: [PATCH] feat: implement native support for SpotiFLAC .spotiflac-ext extensions via Jint JavaScript engine sandbox and repository registry store UI --- allstarr/Controllers/ExtensionController.cs | 88 +++ allstarr/Program.cs | 1 + allstarr/Services/Common/ExtensionManager.cs | 700 ++++++++++++++++++ .../Common/MultiProviderMetadataService.cs | 154 +++- allstarr/allstarr.csproj | 1 + allstarr/wwwroot/index.html | 49 ++ allstarr/wwwroot/js/main.js | 4 + allstarr/wwwroot/js/ui.js | 224 ++++++ 8 files changed, 1201 insertions(+), 20 deletions(-) create mode 100644 allstarr/Controllers/ExtensionController.cs create mode 100644 allstarr/Services/Common/ExtensionManager.cs diff --git a/allstarr/Controllers/ExtensionController.cs b/allstarr/Controllers/ExtensionController.cs new file mode 100644 index 00000000..ad057ebe --- /dev/null +++ b/allstarr/Controllers/ExtensionController.cs @@ -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 _logger; + + public ExtensionController(ExtensionManager extensionManager, ILogger logger) + { + _extensionManager = extensionManager; + _logger = logger; + } + + [HttpGet("repos")] + public IActionResult GetRepositories() + { + return Ok(_extensionManager.GetConfiguredRepositories()); + } + + [HttpGet("store")] + public async Task 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 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; } = ""; +} diff --git a/allstarr/Program.cs b/allstarr/Program.cs index 3552da0c..f53ea4e3 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -605,6 +605,7 @@ builder.Services.AddSingleton(sp => squidWtfStreamingUrls)); // 3. Status Manager & Multi-Provider Orchestrators +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/allstarr/Services/Common/ExtensionManager.cs b/allstarr/Services/Common/ExtensionManager.cs new file mode 100644 index 00000000..4c01d75f --- /dev/null +++ b/allstarr/Services/Common/ExtensionManager.cs @@ -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 _logger; + private readonly IConfiguration _configuration; + private readonly string _extensionsDir; + + private readonly ConcurrentDictionary _activeExtensions = new(); + + public ExtensionManager( + IHttpClientFactory httpClientFactory, + ILogger 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 GetActiveExtensions() => _activeExtensions.Values.ToList(); + + public ExtensionSandbox? GetExtension(string id) + { + return _activeExtensions.TryGetValue(id.ToLowerInvariant(), out var sandbox) ? sandbox : null; + } + + public List 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> FetchStoreExtensionsAsync(CancellationToken cancellationToken = default) + { + var items = new List(); + 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 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 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(); + 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 _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(); + 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(); + } + + 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>(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 {} + } +} diff --git a/allstarr/Services/Common/MultiProviderMetadataService.cs b/allstarr/Services/Common/MultiProviderMetadataService.cs index a356d164..c652dfca 100644 --- a/allstarr/Services/Common/MultiProviderMetadataService.cs +++ b/allstarr/Services/Common/MultiProviderMetadataService.cs @@ -9,23 +9,25 @@ public class MultiProviderMetadataService : IMusicMetadataService { private readonly IEnumerable _allServices; private readonly ProviderStatusManager _statusManager; + private readonly ExtensionManager _extensionManager; private readonly ILogger _logger; public MultiProviderMetadataService( IEnumerable services, ProviderStatusManager statusManager, + ExtensionManager extensionManager, ILogger logger) { _allServices = services.Where(s => s.GetType() != typeof(MultiProviderMetadataService)).ToList(); _statusManager = statusManager; + _extensionManager = extensionManager; _logger = logger; } public async Task> SearchSongsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) { var providers = _statusManager.GetEnabledSearchProviders(); - if (providers.Count == 0) return new List(); - + 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(); } + }).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(); + } }); - 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> SearchAlbumsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) { var providers = _statusManager.GetEnabledSearchProviders(); - if (providers.Count == 0) return new List(); 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(); } + }).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(); + } }); - 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> SearchArtistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) { var providers = _statusManager.GetEnabledSearchProviders(); - if (providers.Count == 0) return new List(); 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(); } + }).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(); + } }); - 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 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 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 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 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 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> GetArtistAlbumsAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { + var ext = _extensionManager.GetExtension(externalProvider); + if (ext != null) return new List(); // Extensions don't have separate artist-albums endpoint usually + var service = GetMetadataServiceByName(externalProvider); if (service == null) return new List(); return await service.GetArtistAlbumsAsync(externalProvider, externalId, cancellationToken); diff --git a/allstarr/allstarr.csproj b/allstarr/allstarr.csproj index 1d20344e..8cc95e08 100644 --- a/allstarr/allstarr.csproj +++ b/allstarr/allstarr.csproj @@ -19,6 +19,7 @@ + diff --git a/allstarr/wwwroot/index.html b/allstarr/wwwroot/index.html index 5940226a..69046b79 100644 --- a/allstarr/wwwroot/index.html +++ b/allstarr/wwwroot/index.html @@ -111,6 +111,7 @@
Kept Downloads
Services
Configuration
+
Extensions
Report Issues
API Analytics
@@ -1139,6 +1140,54 @@ + +
+ +
+

Community Extensions (.spotiflac-ext)

+
+ 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. +
+
+ 💡 Where to find extensions: You can enter any SpotiFLAC Extension Store registry link (such as the official repository https://raw.githubusercontent.com/spotiflacapp/SpotiFLAC-Extension/main/registry.json) to populate the store below. +
+
+ + +
+

Extension Registries

+
+ + +
+
    + +
+
+ + +
+

Extension Store

+
+ Browse and install community plugins from your registered registries. +
+
+ +
+
+ + +
+

Installed Extensions

+
+ Currently active and running JavaScript sandboxes. +
+
+ +
+
+
+
diff --git a/allstarr/wwwroot/js/main.js b/allstarr/wwwroot/js/main.js index 1a5ccbd8..11b1bffd 100644 --- a/allstarr/wwwroot/js/main.js +++ b/allstarr/wwwroot/js/main.js @@ -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" diff --git a/allstarr/wwwroot/js/ui.js b/allstarr/wwwroot/js/ui.js index 01fd8880..09b71565 100644 --- a/allstarr/wwwroot/js/ui.js +++ b/allstarr/wwwroot/js/ui.js @@ -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 = `
  • No registries configured.
  • `; + } else { + repos.forEach(url => { + list.innerHTML += ` +
  • + ${url} + +
  • + `; + }); + } + } + } 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 = ` +
    + No extensions installed. Browse the store below to install extensions. +
    `; + } else { + items.forEach(ext => { + const typeLabels = ext.types.map(t => `${t}`).join(" "); + list.innerHTML += ` +
    +
    +
    +

    ${ext.displayName}

    + v${ext.version} +
    +
    + ${typeLabels} +
    +

    ${ext.description}

    +
    +
    + +
    +
    + `; + }); + } + } + } 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 = ` +
    + No extensions available. Add an extension registry repository above. +
    `; + } else { + items.forEach(ext => { + const actionBtn = ext.isInstalled + ? `` + : ``; + + list.innerHTML += ` +
    +
    +
    +

    ${ext.displayName}

    + v${ext.version} +
    +

    ${ext.description}

    +
    + Registry: ${new URL(ext.repoUrl).hostname} +
    +
    +
    + ${actionBtn} +
    +
    + `; + }); + } + } + } 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"); + } +}; + +