feat: implement native support for SpotiFLAC .spotiflac-ext extensions via Jint JavaScript engine sandbox and repository registry store UI

This commit is contained in:
joshpatra committed 2026-07-07 19:56:47 -04:00
1 parent d7cfe4e163
commit f27bf2c328
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; } = "";
}
+1
View File
@@ -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);
+1
View File
@@ -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" />
+49
View File
@@ -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">
+4
View File
@@ -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"
+224
View File
@@ -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");
}
};