diff --git a/allstarr.Tests/ExtensionManagerTests.cs b/allstarr.Tests/ExtensionManagerTests.cs new file mode 100644 index 00000000..18448cfe --- /dev/null +++ b/allstarr.Tests/ExtensionManagerTests.cs @@ -0,0 +1,69 @@ +using allstarr.Services.Common; + +namespace allstarr.Tests; + +public class ExtensionManagerTests +{ + [Fact] + public void ParseStoreRegistry_SupportsExtensionsWrapperAndSnakeCaseFields() + { + const string json = """ + { + "extensions": [ + { + "id": "amazon-music", + "name": "amazon-music", + "display_name": "Amazon Music", + "description": "Amazon playlist and metadata provider", + "download_url": "https://example.test/amazon.zip", + "version": "1.2.3", + "type": ["metadata", "playlist"] + } + ] + } + """; + + var items = ExtensionManager.ParseStoreRegistry(json, "https://example.test/registry.json"); + + var item = Assert.Single(items); + Assert.Equal("amazon-music", item.Id); + Assert.Equal("Amazon Music", item.DisplayName); + Assert.Equal("https://example.test/amazon.zip", item.DownloadUrl); + Assert.Equal(new[] { "metadata", "playlist" }, item.Types); + Assert.Equal("https://example.test/registry.json", item.RepoUrl); + } + + [Fact] + public void ParseStoreRegistry_SupportsRootArrayAndCamelCaseFields() + { + const string json = """ + [ + { + "id": "deezer-fast", + "displayName": "Deezer Fast", + "downloadUrl": "https://example.test/deezer.zip", + "capabilities": "metadata,playlist" + } + ] + """; + + var items = ExtensionManager.ParseStoreRegistry(json); + + var item = Assert.Single(items); + Assert.Equal("deezer-fast", item.Id); + Assert.Equal("Deezer Fast", item.DisplayName); + Assert.Equal(new[] { "metadata", "playlist" }, item.Types); + } + + [Fact] + public void ParseStoreRegistry_SkipsItemsWithoutDownloadUrl() + { + const string json = """ + { "items": [ { "id": "broken", "displayName": "Broken" } ] } + """; + + var items = ExtensionManager.ParseStoreRegistry(json); + + Assert.Empty(items); + } +} diff --git a/allstarr.Tests/JavaScriptSyntaxTests.cs b/allstarr.Tests/JavaScriptSyntaxTests.cs index 705fd628..be2194f2 100644 --- a/allstarr.Tests/JavaScriptSyntaxTests.cs +++ b/allstarr.Tests/JavaScriptSyntaxTests.cs @@ -1,6 +1,4 @@ using System.Diagnostics; -using System.Text.RegularExpressions; -using Xunit; namespace allstarr.Tests; @@ -14,196 +12,67 @@ public class JavaScriptSyntaxTests public JavaScriptSyntaxTests() { - // Get the path to the wwwroot directory var projectRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "..")); _wwwrootPath = Path.Combine(projectRoot, "allstarr", "wwwroot"); } [Fact] - public void AppJs_ShouldHaveValidSyntax() + public void WwwrootJavaScriptFiles_ShouldHaveValidSyntax() { - var filePath = Path.Combine(_wwwrootPath, "app.js"); - Assert.True(File.Exists(filePath), $"app.js not found at {filePath}"); + var files = Directory + .EnumerateFiles(_wwwrootPath, "*.js", SearchOption.AllDirectories) + .OrderBy(path => path, StringComparer.Ordinal) + .ToList(); - var isValid = ValidateJavaScriptSyntax(filePath, out var error); - Assert.True(isValid, $"app.js has syntax errors:\n{error}"); - } + Assert.NotEmpty(files); - [Fact] - public void SpotifyMappingsJs_ShouldHaveValidSyntax() - { - var filePath = Path.Combine(_wwwrootPath, "spotify-mappings.js"); - Assert.True(File.Exists(filePath), $"spotify-mappings.js not found at {filePath}"); - - var isValid = ValidateJavaScriptSyntax(filePath, out var error); - Assert.True(isValid, $"spotify-mappings.js has syntax errors:\n{error}"); - } - - [Fact] - public void ModularJs_UtilsShouldHaveValidSyntax() - { - var filePath = Path.Combine(_wwwrootPath, "js", "utils.js"); - Assert.True(File.Exists(filePath), $"js/utils.js not found at {filePath}"); - - var isValid = ValidateJavaScriptSyntax(filePath, out var error); - Assert.True(isValid, $"js/utils.js has syntax errors:\n{error}"); - } - - [Fact] - public void ModularJs_ApiShouldHaveValidSyntax() - { - var filePath = Path.Combine(_wwwrootPath, "js", "api.js"); - Assert.True(File.Exists(filePath), $"js/api.js not found at {filePath}"); - - var isValid = ValidateJavaScriptSyntax(filePath, out var error); - Assert.True(isValid, $"js/api.js has syntax errors:\n{error}"); - } - - [Fact] - public void ModularJs_MainShouldHaveValidSyntax() - { - var filePath = Path.Combine(_wwwrootPath, "js", "main.js"); - Assert.True(File.Exists(filePath), $"js/main.js not found at {filePath}"); - - var isValid = ValidateJavaScriptSyntax(filePath, out var error); - Assert.True(isValid, $"js/main.js has syntax errors:\n{error}"); - } - - [Fact] - public void ModularJs_ExtractedModulesShouldHaveValidSyntax() - { - var moduleFiles = new[] + foreach (var filePath in files) { - "settings-editor.js", - "auth-session.js", - "dashboard-data.js", - "operations.js", - "playlist-admin.js", - "scrobbling-admin.js" - }; - - foreach (var moduleFile in moduleFiles) - { - var filePath = Path.Combine(_wwwrootPath, "js", moduleFile); - Assert.True(File.Exists(filePath), $"js/{moduleFile} not found at {filePath}"); - var isValid = ValidateJavaScriptSyntax(filePath, out var error); - Assert.True(isValid, $"js/{moduleFile} has syntax errors:\n{error}"); + Assert.True(isValid, $"{Path.GetRelativePath(_wwwrootPath, filePath)} has syntax errors:\n{error}"); } } [Fact] - public void AppJs_ShouldBeDeprecated() + public void IndexHtml_ShouldLoadLitWebUiEntryPoint() { - var filePath = Path.Combine(_wwwrootPath, "app.js"); - var content = File.ReadAllText(filePath); + var indexPath = Path.Combine(_wwwrootPath, "index.html"); + var content = File.ReadAllText(indexPath); - Assert.Contains("DEPRECATED", content); - Assert.Contains("main.js", content); + Assert.Contains("", content); + Assert.Contains("/js/webui.js", content); + Assert.Contains("/css/tokens.css", content); + Assert.Contains("/css/base.css", content); } [Fact] - public void MainJs_ShouldBeComplete() + public void WebUi_ShouldUseSchemaDrivenLitShell() { - var mainPath = Path.Combine(_wwwrootPath, "js", "main.js"); - var dashboardPath = Path.Combine(_wwwrootPath, "js", "dashboard-data.js"); - var settingsPath = Path.Combine(_wwwrootPath, "js", "settings-editor.js"); - var authPath = Path.Combine(_wwwrootPath, "js", "auth-session.js"); - var operationsPath = Path.Combine(_wwwrootPath, "js", "operations.js"); - var playlistPath = Path.Combine(_wwwrootPath, "js", "playlist-admin.js"); - var scrobblingPath = Path.Combine(_wwwrootPath, "js", "scrobbling-admin.js"); + var webuiPath = Path.Combine(_wwwrootPath, "js", "webui.js"); + var content = File.ReadAllText(webuiPath); - Assert.True(File.Exists(mainPath), $"js/main.js not found at {mainPath}"); - Assert.True(File.Exists(dashboardPath), $"js/dashboard-data.js not found at {dashboardPath}"); - Assert.True(File.Exists(settingsPath), $"js/settings-editor.js not found at {settingsPath}"); - Assert.True(File.Exists(authPath), $"js/auth-session.js not found at {authPath}"); - Assert.True(File.Exists(operationsPath), $"js/operations.js not found at {operationsPath}"); - Assert.True(File.Exists(playlistPath), $"js/playlist-admin.js not found at {playlistPath}"); - Assert.True(File.Exists(scrobblingPath), $"js/scrobbling-admin.js not found at {scrobblingPath}"); - - var mainContent = File.ReadAllText(mainPath); - var dashboardContent = File.ReadAllText(dashboardPath); - var settingsContent = File.ReadAllText(settingsPath); - var authContent = File.ReadAllText(authPath); - var operationsContent = File.ReadAllText(operationsPath); - var playlistContent = File.ReadAllText(playlistPath); - var scrobblingContent = File.ReadAllText(scrobblingPath); - - Assert.Contains("DOMContentLoaded", mainContent); - Assert.Contains("authSession.bootstrapAuth()", mainContent); - Assert.Contains("initDashboardData", mainContent); - - Assert.Contains("window.fetchStatus", dashboardContent); - Assert.Contains("window.fetchPlaylists", dashboardContent); - Assert.Contains("window.fetchConfig", dashboardContent); - Assert.Contains("window.fetchEndpointUsage", dashboardContent); - - Assert.Contains("window.openEditSetting", settingsContent); - Assert.Contains("window.saveEditSetting", settingsContent); - Assert.Contains("window.logoutAdminSession", authContent); - Assert.Contains("window.restartContainer", operationsContent); - Assert.Contains("window.linkPlaylist", playlistContent); - Assert.Contains("window.loadScrobblingConfig", scrobblingContent); + Assert.Contains("cdn.jsdelivr.net/npm/lit", content); + Assert.Contains("/api/admin/ui/schema", content); + Assert.Contains("customElements.define(\"allstarr-app\"", content); + Assert.Contains("EventSource(\"/api/admin/downloads/activity\")", content); + Assert.Contains("EXTENSION_REPOSITORIES", content); } [Fact] - public void AppJs_ShouldHaveBalancedBraces() + public void SpotifyMappingsHtml_ShouldRedirectToIntegratedRoute() { - var filePath = Path.Combine(_wwwrootPath, "js", "main.js"); - var content = File.ReadAllText(filePath); + var mappingsPath = Path.Combine(_wwwrootPath, "spotify-mappings.html"); + var content = File.ReadAllText(mappingsPath); - var openBraces = content.Count(c => c == '{'); - var closeBraces = content.Count(c => c == '}'); - - Assert.Equal(openBraces, closeBraces); + Assert.Contains("/#/library/mappings", content); } - [Fact] - public void AppJs_ShouldHaveBalancedParentheses() - { - var filePath = Path.Combine(_wwwrootPath, "js", "main.js"); - - // Validate syntax with Node.js instead of counting parentheses. - string error; - var isValid = ValidateJavaScriptSyntax(filePath, out error); - - Assert.True(isValid, $"JavaScript syntax validation failed: {error}"); - } - - [Fact] - public void ApiJs_ShouldCentralizeFetchHandling() - { - var filePath = Path.Combine(_wwwrootPath, "js", "api.js"); - var content = File.ReadAllText(filePath); - - Assert.Contains("async function requestJson", content); - Assert.Contains("async function requestBlob", content); - Assert.Contains("async function requestOptionalJson", content); - - var fetchCallCount = Regex.Matches(content, @"\bfetch\(").Count; - Assert.Equal(3, fetchCallCount); - } - - [Fact] - public void ScrobblingAdmin_ShouldUseApiWrappersInsteadOfDirectFetch() - { - var filePath = Path.Combine(_wwwrootPath, "js", "scrobbling-admin.js"); - var content = File.ReadAllText(filePath); - - Assert.DoesNotContain("fetch(", content); - Assert.Contains("API.fetchScrobblingStatus()", content); - Assert.Contains("API.updateLocalTracksScrobbling", content); - Assert.Contains("API.authenticateLastFm()", content); - Assert.Contains("API.validateListenBrainzToken", content); - } - - private bool ValidateJavaScriptSyntax(string filePath, out string error) + private static bool ValidateJavaScriptSyntax(string filePath, out string error) { error = string.Empty; try { - // Use Node.js to check syntax var process = new Process { StartInfo = new ProcessStartInfo @@ -236,5 +105,4 @@ public class JavaScriptSyntaxTests return false; } } - } diff --git a/allstarr.Tests/RuntimeEnvConfigurationTests.cs b/allstarr.Tests/RuntimeEnvConfigurationTests.cs index 0db11541..38f53a99 100644 --- a/allstarr.Tests/RuntimeEnvConfigurationTests.cs +++ b/allstarr.Tests/RuntimeEnvConfigurationTests.cs @@ -46,6 +46,25 @@ public sealed class RuntimeEnvConfigurationTests : IDisposable Assert.Empty(mappings); } + [Theory] + [InlineData("MULTI_PROVIDER_METADATA_ORDER")] + [InlineData("MULTI_PROVIDER_DOWNLOAD_ORDER")] + [InlineData("MULTI_PROVIDER_PLAYLIST_ORDER")] + [InlineData("MULTI_PROVIDER_LYRICS_ORDER")] + [InlineData("MULTI_PROVIDER_ENABLED_SEARCH")] + [InlineData("MULTI_PROVIDER_ENABLED_PLAYLIST")] + [InlineData("EXTENSION_REPOSITORIES")] + public void MapEnvVarToConfiguration_MapsFlatPlatformKeys(string key) + { + var mappings = RuntimeEnvConfiguration + .MapEnvVarToConfiguration(key, "value") + .ToList(); + + var mapping = Assert.Single(mappings); + Assert.Equal(key, mapping.Key); + Assert.Equal("value", mapping.Value); + } + [Fact] public void LoadDotEnvOverrides_StripsQuotesAndSupportsDoubleUnderscoreKeys() { diff --git a/allstarr/Controllers/AdminUiController.cs b/allstarr/Controllers/AdminUiController.cs new file mode 100644 index 00000000..796c2928 --- /dev/null +++ b/allstarr/Controllers/AdminUiController.cs @@ -0,0 +1,361 @@ +using allstarr.Filters; +using allstarr.Models.Admin; +using allstarr.Models.Settings; +using allstarr.Services.Common; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace allstarr.Controllers; + +[ApiController] +[Route("api/admin/ui")] +[ServiceFilter(typeof(AdminPortFilter))] +public class AdminUiController : ControllerBase +{ + private readonly IConfiguration _configuration; + private readonly SpotifyApiSettings _spotifyApiSettings; + private readonly DeezerSettings _deezerSettings; + private readonly QobuzSettings _qobuzSettings; + private readonly SquidWTFSettings _squidWtfSettings; + private readonly AppleMusicSettings _appleMusicSettings; + private readonly MusicBrainzSettings _musicBrainzSettings; + private readonly ExtensionManager _extensionManager; + + public AdminUiController( + IConfiguration configuration, + IOptions spotifyApiSettings, + IOptions deezerSettings, + IOptions qobuzSettings, + IOptions squidWtfSettings, + IOptions appleMusicSettings, + IOptions musicBrainzSettings, + ExtensionManager extensionManager) + { + _configuration = configuration; + _spotifyApiSettings = spotifyApiSettings.Value; + _deezerSettings = deezerSettings.Value; + _qobuzSettings = qobuzSettings.Value; + _squidWtfSettings = squidWtfSettings.Value; + _appleMusicSettings = appleMusicSettings.Value; + _musicBrainzSettings = musicBrainzSettings.Value; + _extensionManager = extensionManager; + } + + [HttpGet("schema")] + public IActionResult GetSchema() + { + var activeBackend = _configuration.GetValue("Backend:Type") ?? "Jellyfin"; + var repositories = _extensionManager.GetConfiguredRepositories(); + var installedExtensionCount = _extensionManager.GetActiveExtensions().Count; + + var schema = new AdminUiSchemaResponse + { + ActiveBackend = activeBackend, + Routes = BuildRoutes(), + Backends = BuildBackends(), + Providers = BuildProviders(installedExtensionCount), + MultiProviderCategories = ["metadata", "download", "playlist", "lyrics"], + PriorityGroups = BuildPriorityGroups(), + ConfigSections = BuildConfigSections(), + ExtensionStore = new AdminUiExtensionStore + { + Repositories = repositories, + RegistryEnvKey = "EXTENSION_REPOSITORIES", + StoreEndpoint = "/api/admin/extensions/store", + InstalledEndpoint = "/api/admin/extensions/installed" + }, + PluginCapabilities = + [ + new() + { + Id = "metadata", + Label = "Metadata and search", + Description = "Installed extensions participate in multi-provider song, album, and artist search.", + Supported = true + }, + new() + { + Id = "playlist", + Label = "Playlist discovery", + Description = "Built-in providers expose playlist search and track expansion through the provider contract.", + Supported = true + }, + new() + { + Id = "download", + Label = "Download providers", + Description = "Download execution is currently limited to built-in provider services.", + Supported = false + }, + new() + { + Id = "lyrics", + Label = "Lyrics providers", + Description = "Lyrics are routed through the built-in Spotify sidecar, LyricsPlus, and LRCLib orchestrator.", + Supported = false + } + ] + }; + + return Ok(schema); + } + + private static List BuildRoutes() => + [ + Route("home", "#/home", "Home", "home"), + Route("library", "#/library", "Library", "library"), + Route("sources", "#/sources", "Sources", "sources"), + Route("activity", "#/activity", "Activity", "activity"), + Route("settings", "#/settings", "Settings", "settings") + ]; + + private static AdminUiRoute Route(string id, string path, string label, string zone) => + new() { Id = id, Path = path, Label = label, Zone = zone }; + + private static List BuildBackends() => + [ + new() + { + Id = "Subsonic", + Name = "Navidrome / Subsonic", + Icon = "subsonic", + ConfigSchema = + [ + Field("SUBSONIC_URL", "Server URL", "url", "subsonic.url"), + Field("MUSIC_SERVICE", "Default music service", "select", "musicService", ["SquidWTF", "AppleMusic", "Deezer", "Qobuz"]), + Field("ENABLE_EXTERNAL_PLAYLISTS", "External playlists", "toggle", "enableExternalPlaylists") + ] + }, + new() + { + Id = "Jellyfin", + Name = "Jellyfin", + Icon = "jellyfin", + ConfigSchema = + [ + Field("JELLYFIN_URL", "Server URL", "url", "jellyfin.url"), + Field("JELLYFIN_API_KEY", "API key", "password", "jellyfin.apiKey", sensitive: true), + Field("JELLYFIN_USER_ID", "User ID", "text", "jellyfin.userId"), + Field("JELLYFIN_LIBRARY_ID", "Music library ID", "text", "jellyfin.libraryId") + ] + } + ]; + + private List BuildProviders(int installedExtensionCount) => + [ + new() + { + Id = "spotify", + Name = "Spotify", + Icon = "spotify", + Status = _spotifyApiSettings.Enabled + ? (!string.IsNullOrWhiteSpace(_spotifyApiSettings.SessionCookie) ? "configured" : "needs_config") + : "disabled", + Categories = ["metadata", "playlist", "lyrics"], + ConfigSchema = + [ + Field("SPOTIFY_API_ENABLED", "Enabled", "toggle", "spotifyApi.enabled"), + Field("SPOTIFY_API_SESSION_COOKIE", "sp_dc session cookie", "password", "spotifyApi.sessionCookie", sensitive: true), + Field("SPOTIFY_API_CACHE_DURATION_MINUTES", "Cache minutes", "number", "spotifyApi.cacheDurationMinutes", min: 1), + Field("SPOTIFY_API_PREFER_ISRC_MATCHING", "Prefer ISRC matching", "toggle", "spotifyApi.preferIsrcMatching") + ] + }, + new() + { + Id = "applemusic", + Name = "Apple Music", + Icon = "applemusic", + Status = string.IsNullOrWhiteSpace(_appleMusicSettings.BaseUrl) ? "needs_config" : "configured", + Categories = ["metadata", "download", "playlist"], + ConfigSchema = + [ + Field("APPLE_MUSIC_AIO_URL", "Sidecar URL", "url", "appleMusic.baseUrl"), + Field("APPLE_MUSIC_QUALITY", "Quality", "select", "appleMusic.quality", ["alac-16-44", "alac-24-48", "alac-24-96", "alac-24-192"]) + ] + }, + new() + { + Id = "deezer", + Name = "Deezer", + Icon = "deezer", + Status = string.IsNullOrWhiteSpace(_deezerSettings.Arl) ? "needs_config" : "configured", + Categories = ["metadata", "download", "playlist"], + ConfigSchema = + [ + Field("DEEZER_ARL", "ARL cookie", "password", "deezer.arl", sensitive: true), + Field("DEEZER_ARL_FALLBACK", "Fallback ARL cookie", "password", "deezer.arlFallback", sensitive: true), + Field("DEEZER_QUALITY", "Quality", "select", "deezer.quality", ["MP3_128", "MP3_320", "FLAC"]), + Field("DEEZER_MIN_REQUEST_INTERVAL_MS", "Minimum request interval", "number", "deezer.minRequestIntervalMs", min: 0) + ] + }, + new() + { + Id = "qobuz", + Name = "Qobuz", + Icon = "qobuz", + Status = string.IsNullOrWhiteSpace(_qobuzSettings.UserAuthToken) ? "needs_config" : "configured", + Categories = ["metadata", "download", "playlist"], + ConfigSchema = + [ + Field("QOBUZ_USER_AUTH_TOKEN", "User auth token", "password", "qobuz.userAuthToken", sensitive: true), + Field("QOBUZ_USER_ID", "User ID", "text", "qobuz.userId"), + Field("QOBUZ_QUALITY", "Quality", "select", "qobuz.quality", ["MP3_320", "FLAC", "HI_RES"]), + Field("QOBUZ_MIN_REQUEST_INTERVAL_MS", "Minimum request interval", "number", "qobuz.minRequestIntervalMs", min: 0) + ] + }, + new() + { + Id = "squidwtf", + Name = "SquidWTF", + Icon = "squidwtf", + Status = string.IsNullOrWhiteSpace(_squidWtfSettings.Quality) ? "unknown" : "configured", + Categories = ["metadata", "download", "playlist"], + ConfigSchema = + [ + Field("SQUIDWTF_QUALITY", "Quality", "select", "squidWtf.quality", ["LOW", "HIGH", "LOSSLESS"]), + Field("SQUIDWTF_MIN_REQUEST_INTERVAL_MS", "Minimum request interval", "number", "squidWtf.minRequestIntervalMs", min: 0) + ] + }, + new() + { + Id = "musicbrainz", + Name = "MusicBrainz", + Icon = "musicbrainz", + Status = _musicBrainzSettings.Enabled ? "configured" : "disabled", + Categories = ["metadata"], + ConfigSchema = + [ + Field("MUSICBRAINZ_ENABLED", "Enabled", "toggle", "musicBrainz.enabled"), + Field("MUSICBRAINZ_USERNAME", "Username", "text", "musicBrainz.username"), + Field("MUSICBRAINZ_PASSWORD", "Password", "password", "musicBrainz.password", sensitive: true) + ] + }, + new() + { + Id = "extensions", + Name = "Installed extensions", + Icon = "extension", + Status = installedExtensionCount > 0 ? "configured" : "available", + Categories = ["metadata"], + Notes = [$"{installedExtensionCount} installed"] + }, + new() + { + Id = "lyricsplus", + Name = "LyricsPlus", + Icon = "lyrics", + Status = "available", + Categories = ["lyrics"] + }, + new() + { + Id = "lrclib", + Name = "LRCLib", + Icon = "lyrics", + Status = "available", + Categories = ["lyrics"] + } + ]; + + private List BuildPriorityGroups() => + [ + Priority("metadata", "Metadata search priority", "MULTI_PROVIDER_METADATA_ORDER", "MULTI_PROVIDER_ENABLED_SEARCH", + "spotify,applemusic,deezer,qobuz,squidwtf"), + Priority("download", "Download priority", "MULTI_PROVIDER_DOWNLOAD_ORDER", null, + "applemusic,deezer,qobuz,squidwtf"), + Priority("playlist", "Playlist discovery priority", "MULTI_PROVIDER_PLAYLIST_ORDER", "MULTI_PROVIDER_ENABLED_PLAYLIST", + "spotify,applemusic,deezer,qobuz,squidwtf"), + Priority("lyrics", "Lyrics priority", "MULTI_PROVIDER_LYRICS_ORDER", null, + "spotify,lyricsplus,lrclib") + ]; + + private AdminUiPriorityGroup Priority( + string id, + string label, + string envKey, + string? enabledEnvKey, + string fallback) + { + var value = _configuration[envKey] ?? fallback; + return new AdminUiPriorityGroup + { + Id = id, + Label = label, + EnvKey = envKey, + EnabledEnvKey = enabledEnvKey, + Providers = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(p => p.ToLowerInvariant()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList() + }; + } + + private static List BuildConfigSections() => + [ + Section("general", "General", + [ + Field("BACKEND_TYPE", "Backend", "select", "backendType", ["Jellyfin", "Subsonic"]), + Field("MUSIC_SERVICE", "Primary music service", "select", "musicService", ["SquidWTF", "AppleMusic", "Deezer", "Qobuz"]), + Field("STORAGE_MODE", "Storage mode", "select", "library.storageMode", ["Permanent", "Cache"]), + Field("DOWNLOAD_MODE", "Download mode", "select", "library.downloadMode", ["Track", "Album"]), + Field("EXPLICIT_FILTER", "Explicit filter", "select", "explicitFilter", ["All", "ExplicitOnly", "CleanOnly"]), + Field("REDIS_ENABLED", "Redis", "toggle", "redisEnabled") + ]), + Section("paths", "Library paths", + [ + Field("LIBRARY_DOWNLOAD_PATH", "Download path", "text", "library.downloadPath"), + Field("LIBRARY_KEPT_PATH", "Kept downloads path", "text", "library.keptPath"), + Field("PLAYLISTS_DIRECTORY", "Playlists directory", "text", "playlistsDirectory") + ]), + Section("cache", "Cache", + [ + Field("CACHE_DURATION_HOURS", "Track cache hours", "number", "library.cacheDurationHours", min: 1), + Field("CACHE_SEARCH_RESULTS_MINUTES", "Search results minutes", "number", "cache.searchResultsMinutes", min: 1), + Field("CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS", "Spotify playlist items hours", "number", "cache.spotifyPlaylistItemsHours", min: 1), + Field("CACHE_SPOTIFY_MATCHED_TRACKS_DAYS", "Matched tracks days", "number", "cache.spotifyMatchedTracksDays", min: 1), + Field("CACHE_LYRICS_DAYS", "Lyrics days", "number", "cache.lyricsDays", min: 1), + Field("CACHE_METADATA_DAYS", "Metadata days", "number", "cache.metadataDays", min: 1), + Field("CACHE_PROXY_IMAGES_DAYS", "Proxy images days", "number", "cache.proxyImagesDays", min: 1), + Field("CACHE_TRANSCODE_MINUTES", "Transcode cache minutes", "number", "cache.transcodeCacheMinutes", min: 1) + ]), + Section("network", "Network and security", + [ + Field("ADMIN_BIND_ANY_IP", "Bind admin on all interfaces", "toggle", "admin.bindAnyIp"), + Field("ADMIN_TRUSTED_SUBNETS", "Trusted admin subnets", "text", "admin.trustedSubnets"), + Field("DEBUG_LOG_ALL_REQUESTS", "Request usage logging", "toggle", "debug.logAllRequests") + ]), + Section("spotify-import", "Spotify import", + [ + Field("SPOTIFY_IMPORT_ENABLED", "Enabled", "toggle", "spotifyImport.enabled"), + Field("SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS", "Matching interval hours", "number", "spotifyImport.matchingIntervalHours", min: 1) + ]) + ]; + + private static AdminUiConfigSection Section(string id, string label, List fields) => + new() { Id = id, Label = label, Fields = fields }; + + private static AdminUiConfigField Field( + string key, + string label, + string type, + string? valuePath, + List? options = null, + string? placeholder = null, + bool sensitive = false, + bool requiresRestart = true, + int? min = null, + int? max = null) => + new() + { + Key = key, + Label = label, + Type = type, + ValuePath = valuePath, + Options = options ?? [], + Placeholder = placeholder, + Sensitive = sensitive, + RequiresRestart = requiresRestart, + Min = min, + Max = max + }; +} diff --git a/allstarr/Controllers/ConfigController.cs b/allstarr/Controllers/ConfigController.cs index d22fee89..673ac573 100644 --- a/allstarr/Controllers/ConfigController.cs +++ b/allstarr/Controllers/ConfigController.cs @@ -196,6 +196,10 @@ public class ConfigController : ControllerBase userId = GetEnvString(envVars, "JELLYFIN_USER_ID", _jellyfinSettings.UserId ?? string.Empty), libraryId = GetEnvString(envVars, "JELLYFIN_LIBRARY_ID", _jellyfinSettings.LibraryId ?? string.Empty) }, + subsonic = new + { + url = GetEnvString(envVars, "SUBSONIC_URL", _subsonicSettings.Url ?? string.Empty) + }, library = new { downloadPath = isCacheStorageMode @@ -251,6 +255,14 @@ public class ConfigController : ControllerBase proxyImagesDays = GetEnvInt(envVars, "CACHE_PROXY_IMAGES_DAYS", _configuration.GetValue("Cache:ProxyImagesDays", 14)), transcodeCacheMinutes = GetEnvInt(envVars, "CACHE_TRANSCODE_MINUTES", _configuration.GetValue("Cache:TranscodeCacheMinutes", 60)) }, + extensions = new + { + repositories = GetEnvString( + envVars, + "EXTENSION_REPOSITORIES", + _configuration.GetValue("EXTENSION_REPOSITORIES") ?? + "https://raw.githubusercontent.com/spotiflacapp/SpotiFLAC-Extension/main/registry.json") + }, scrobbling = await GetScrobblingSettingsFromEnvAsync() }); } diff --git a/allstarr/Controllers/ExtensionController.cs b/allstarr/Controllers/ExtensionController.cs index ad057ebe..3de81419 100644 --- a/allstarr/Controllers/ExtensionController.cs +++ b/allstarr/Controllers/ExtensionController.cs @@ -27,8 +27,8 @@ public class ExtensionController : ControllerBase [HttpGet("store")] public async Task GetStoreExtensions(CancellationToken cancellationToken) { - var items = await _extensionManager.FetchStoreExtensionsAsync(cancellationToken); - return Ok(items); + var catalog = await _extensionManager.FetchStoreCatalogAsync(cancellationToken); + return Ok(catalog); } [HttpGet("installed")] @@ -51,12 +51,21 @@ public class ExtensionController : ControllerBase [HttpPost("install")] public async Task InstallExtension([FromBody] InstallRequest request, CancellationToken cancellationToken) { - if (string.IsNullOrEmpty(request.DownloadUrl)) + var downloadUrl = request.DownloadUrl; + if (string.IsNullOrWhiteSpace(downloadUrl) && !string.IsNullOrWhiteSpace(request.Id)) { - return BadRequest(new { success = false, message = "Download URL is required." }); + var catalog = await _extensionManager.FetchStoreCatalogAsync(cancellationToken); + downloadUrl = catalog.Items + .FirstOrDefault(item => item.Id.Equals(request.Id, StringComparison.OrdinalIgnoreCase)) + ?.DownloadUrl; } - var success = await _extensionManager.InstallExtensionAsync(request.DownloadUrl, cancellationToken); + if (string.IsNullOrEmpty(downloadUrl)) + { + return BadRequest(new { success = false, message = "Download URL or store extension ID is required." }); + } + + var success = await _extensionManager.InstallExtensionAsync(downloadUrl, cancellationToken); if (success) { return Ok(new { success = true, message = "Extension installed and loaded successfully." }); @@ -84,5 +93,6 @@ public class ExtensionController : ControllerBase public class InstallRequest { + public string Id { get; set; } = ""; public string DownloadUrl { get; set; } = ""; } diff --git a/allstarr/Controllers/PlaylistController.cs b/allstarr/Controllers/PlaylistController.cs index 085f7333..61503767 100644 --- a/allstarr/Controllers/PlaylistController.cs +++ b/allstarr/Controllers/PlaylistController.cs @@ -1315,6 +1315,139 @@ public class PlaylistController : ControllerBase } } + /// + /// Search a specific external provider for playlists for the admin UI. + /// + [HttpGet("external/playlists/search")] + public async Task SearchExternalPlaylists( + [FromQuery] string query, + [FromQuery] string provider = "deezer", + [FromQuery] int limit = 20) + { + if (string.IsNullOrWhiteSpace(query)) + { + return BadRequest(new { error = "Query is required" }); + } + + var normalizedProvider = (provider ?? string.Empty).Trim().ToLowerInvariant(); + if (!IsSupportedExternalPlaylistProvider(normalizedProvider)) + { + return BadRequest(new { error = "Unsupported provider" }); + } + + try + { + var service = GetConcreteMetadataServiceByName(normalizedProvider); + if (service == null) + { + return BadRequest(new { error = $"Provider '{normalizedProvider}' is not registered" }); + } + + var playlists = await service.SearchPlaylistsAsync( + query.Trim(), + Math.Clamp(limit, 1, 50), + HttpContext.RequestAborted); + + var results = playlists + .Where(p => !string.IsNullOrWhiteSpace(p.ExternalId)) + .Select(p => new + { + id = p.Id, + externalId = p.ExternalId, + externalProvider = string.IsNullOrWhiteSpace(p.Provider) ? normalizedProvider : p.Provider, + name = p.Name, + description = p.Description, + curatorName = p.CuratorName, + trackCount = p.TrackCount, + duration = p.Duration, + coverUrl = p.CoverUrl + }) + .ToList(); + + return Ok(new { results }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to search external playlists for provider {Provider}", provider); + return StatusCode(500, new { error = "Failed to search external playlists" }); + } + } + + /// + /// Preview tracks from a specific external provider playlist. + /// + [HttpGet("external/playlists/{provider}/{externalId}/tracks")] + public async Task GetExternalPlaylistTracks( + string provider, + string externalId, + [FromQuery] int limit = 50) + { + var normalizedProvider = (provider ?? string.Empty).Trim().ToLowerInvariant(); + if (!IsSupportedExternalPlaylistProvider(normalizedProvider)) + { + return BadRequest(new { error = "Unsupported provider" }); + } + + if (string.IsNullOrWhiteSpace(externalId)) + { + return BadRequest(new { error = "External playlist ID is required" }); + } + + try + { + var service = GetConcreteMetadataServiceByName(normalizedProvider); + if (service == null) + { + return BadRequest(new { error = $"Provider '{normalizedProvider}' is not registered" }); + } + + var tracks = await service.GetPlaylistTracksAsync( + normalizedProvider, + externalId.Trim(), + HttpContext.RequestAborted); + + var results = tracks + .Take(Math.Clamp(limit, 1, 200)) + .Select(song => new + { + id = song.Id, + externalId = song.ExternalId, + externalProvider = song.ExternalProvider ?? normalizedProvider, + title = song.Title, + artist = song.Artist, + album = song.Album, + duration = song.Duration, + isrc = song.Isrc + }) + .ToList(); + + return Ok(new { results, count = results.Count }); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to fetch tracks for external playlist {Provider}/{ExternalId}", + provider, + externalId); + return StatusCode(500, new { error = "Failed to fetch external playlist tracks" }); + } + } + + private static bool IsSupportedExternalPlaylistProvider(string provider) => + provider is "deezer" or "qobuz" or "squidwtf" or "applemusic"; + + private IConcreteMetadataService? GetConcreteMetadataServiceByName(string provider) + { + var normalizedProvider = provider.ToLowerInvariant(); + var services = HttpContext.RequestServices.GetServices(); + + return services.FirstOrDefault(s => + s.GetType().Name.StartsWith(normalizedProvider, StringComparison.OrdinalIgnoreCase) || + (normalizedProvider == "squidwtf" && s.GetType().Name.StartsWith("SquidWTF", StringComparison.OrdinalIgnoreCase)) || + (normalizedProvider == "applemusic" && s.GetType().Name.StartsWith("AppleMusic", StringComparison.OrdinalIgnoreCase))); + } + /// /// Get track details by Jellyfin ID (for URL-based mapping) /// diff --git a/allstarr/Models/Admin/AdminUiSchema.cs b/allstarr/Models/Admin/AdminUiSchema.cs new file mode 100644 index 00000000..1cb48788 --- /dev/null +++ b/allstarr/Models/Admin/AdminUiSchema.cs @@ -0,0 +1,180 @@ +using System.Text.Json.Serialization; + +namespace allstarr.Models.Admin; + +public sealed class AdminUiSchemaResponse +{ + [JsonPropertyName("routes")] + public List Routes { get; set; } = []; + + [JsonPropertyName("backends")] + public List Backends { get; set; } = []; + + [JsonPropertyName("activeBackend")] + public string ActiveBackend { get; set; } = "Jellyfin"; + + [JsonPropertyName("providers")] + public List Providers { get; set; } = []; + + [JsonPropertyName("multiProviderCategories")] + public List MultiProviderCategories { get; set; } = []; + + [JsonPropertyName("priorityGroups")] + public List PriorityGroups { get; set; } = []; + + [JsonPropertyName("configSections")] + public List ConfigSections { get; set; } = []; + + [JsonPropertyName("extensionStore")] + public AdminUiExtensionStore ExtensionStore { get; set; } = new(); + + [JsonPropertyName("pluginCapabilities")] + public List PluginCapabilities { get; set; } = []; +} + +public sealed class AdminUiRoute +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; + + [JsonPropertyName("label")] + public string Label { get; set; } = string.Empty; + + [JsonPropertyName("zone")] + public string Zone { get; set; } = string.Empty; +} + +public sealed class AdminUiBackend +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("icon")] + public string Icon { get; set; } = string.Empty; + + [JsonPropertyName("configSchema")] + public List ConfigSchema { get; set; } = []; +} + +public sealed class AdminUiProvider +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("icon")] + public string Icon { get; set; } = string.Empty; + + [JsonPropertyName("status")] + public string Status { get; set; } = "unknown"; + + [JsonPropertyName("categories")] + public List Categories { get; set; } = []; + + [JsonPropertyName("configSchema")] + public List ConfigSchema { get; set; } = []; + + [JsonPropertyName("notes")] + public List Notes { get; set; } = []; +} + +public sealed class AdminUiPriorityGroup +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("label")] + public string Label { get; set; } = string.Empty; + + [JsonPropertyName("envKey")] + public string EnvKey { get; set; } = string.Empty; + + [JsonPropertyName("enabledEnvKey")] + public string? EnabledEnvKey { get; set; } + + [JsonPropertyName("providers")] + public List Providers { get; set; } = []; +} + +public sealed class AdminUiConfigSection +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("label")] + public string Label { get; set; } = string.Empty; + + [JsonPropertyName("fields")] + public List Fields { get; set; } = []; +} + +public sealed class AdminUiConfigField +{ + [JsonPropertyName("key")] + public string Key { get; set; } = string.Empty; + + [JsonPropertyName("label")] + public string Label { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public string Type { get; set; } = "text"; + + [JsonPropertyName("valuePath")] + public string? ValuePath { get; set; } + + [JsonPropertyName("options")] + public List Options { get; set; } = []; + + [JsonPropertyName("placeholder")] + public string? Placeholder { get; set; } + + [JsonPropertyName("sensitive")] + public bool Sensitive { get; set; } + + [JsonPropertyName("requiresRestart")] + public bool RequiresRestart { get; set; } = true; + + [JsonPropertyName("min")] + public int? Min { get; set; } + + [JsonPropertyName("max")] + public int? Max { get; set; } +} + +public sealed class AdminUiExtensionStore +{ + [JsonPropertyName("repositories")] + public List Repositories { get; set; } = []; + + [JsonPropertyName("registryEnvKey")] + public string RegistryEnvKey { get; set; } = "EXTENSION_REPOSITORIES"; + + [JsonPropertyName("storeEndpoint")] + public string StoreEndpoint { get; set; } = "/api/admin/extensions/store"; + + [JsonPropertyName("installedEndpoint")] + public string InstalledEndpoint { get; set; } = "/api/admin/extensions/installed"; +} + +public sealed class AdminUiPluginCapability +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("label")] + public string Label { get; set; } = string.Empty; + + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + [JsonPropertyName("supported")] + public bool Supported { get; set; } +} diff --git a/allstarr/Program.cs b/allstarr/Program.cs index 349ee136..76f2c06c 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -1020,6 +1020,8 @@ class BackendControllerFeatureProvider : Microsoft.AspNetCore.Mvc.Controllers.Co typeInfo.Name == "MappingController" || typeInfo.Name == "ScrobblingAdminController" || typeInfo.Name == "AppleMusicController" || + typeInfo.Name == "ExtensionController" || + typeInfo.Name == "AdminUiController" || typeInfo.Name == "DownloadActivityController") { return true; diff --git a/allstarr/Services/Common/ExtensionManager.cs b/allstarr/Services/Common/ExtensionManager.cs index 40fa6f60..246d084b 100644 --- a/allstarr/Services/Common/ExtensionManager.cs +++ b/allstarr/Services/Common/ExtensionManager.cs @@ -3,10 +3,12 @@ using System.IO.Compression; using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; using Jint; using Jint.Native; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using allstarr.Services.Admin; using allstarr.Models.Domain; using allstarr.Models.Search; using allstarr.Models.Subsonic; @@ -18,6 +20,7 @@ public class ExtensionManager private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; private readonly IConfiguration _configuration; + private readonly AdminHelperService _adminHelperService; private readonly string _extensionsDir; private readonly ConcurrentDictionary _activeExtensions = new(); @@ -25,11 +28,13 @@ public class ExtensionManager public ExtensionManager( IHttpClientFactory httpClientFactory, ILogger logger, - IConfiguration configuration) + IConfiguration configuration, + AdminHelperService adminHelperService) { _httpClientFactory = httpClientFactory; _logger = logger; _configuration = configuration; + _adminHelperService = adminHelperService; _extensionsDir = Path.Combine(Directory.GetCurrentDirectory(), "extensions"); if (!Directory.Exists(_extensionsDir)) @@ -50,7 +55,7 @@ public class ExtensionManager public List GetConfiguredRepositories() { - var repos = _configuration["EXTENSION_REPOSITORIES"]; + var repos = ReadExtensionRepositoriesFromEnvFile() ?? _configuration["EXTENSION_REPOSITORIES"]; if (string.IsNullOrWhiteSpace(repos)) { repos = "https://raw.githubusercontent.com/spotiflacapp/SpotiFLAC-Extension/main/registry.json"; @@ -58,10 +63,49 @@ public class ExtensionManager return repos.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); } + private string? ReadExtensionRepositoriesFromEnvFile() + { + try + { + var envPath = _adminHelperService.GetEnvFilePath(); + if (!File.Exists(envPath)) + { + return null; + } + + foreach (var line in File.ReadLines(envPath)) + { + if (AdminHelperService.ShouldSkipEnvLine(line)) + { + continue; + } + + var (key, value) = AdminHelperService.ParseEnvLine(line); + if (key.Equals("EXTENSION_REPOSITORIES", StringComparison.OrdinalIgnoreCase)) + { + return value; + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read extension repositories from .env file"); + } + + return null; + } + public async Task> FetchStoreExtensionsAsync(CancellationToken cancellationToken = default) { - var items = new List(); + var catalog = await FetchStoreCatalogAsync(cancellationToken); + return catalog.Items; + } + + public async Task FetchStoreCatalogAsync(CancellationToken cancellationToken = default) + { + var catalog = new ExtensionStoreResponse(); var repos = GetConfiguredRepositories(); + catalog.Repositories.AddRange(repos); using var client = _httpClientFactory.CreateClient(); client.Timeout = TimeSpan.FromSeconds(5); @@ -74,41 +118,31 @@ public class ExtensionManager 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) + var parsedItems = ParseStoreRegistry(json, repo); + foreach (var item in parsedItems) { - 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 - }); - } + item.IsInstalled = _activeExtensions.ContainsKey(item.Id.ToLowerInvariant()); + catalog.Items.Add(item); } } catch (Exception ex) { _logger.LogError(ex, "Failed to fetch extension store registry from {Repo}", repo); + catalog.Errors.Add(new ExtensionStoreError + { + Repository = repo, + Message = ex.Message + }); } } - return items; + catalog.Items = catalog.Items + .GroupBy(item => item.Id, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .OrderBy(item => item.DisplayName, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return catalog; } public async Task InstallExtensionAsync(string downloadUrl, CancellationToken cancellationToken = default) @@ -127,8 +161,9 @@ public class ExtensionManager Directory.CreateDirectory(tempDir); ZipFile.ExtractToDirectory(tempFile, tempDir, overwriteFiles: true); - var manifestPath = Path.Combine(tempDir, "manifest.json"); - var indexJsPath = Path.Combine(tempDir, "index.js"); + var packageRoot = ResolveExtensionPackageRoot(tempDir); + var manifestPath = Path.Combine(packageRoot, "manifest.json"); + var indexJsPath = Path.Combine(packageRoot, "index.js"); if (!File.Exists(manifestPath) || !File.Exists(indexJsPath)) { @@ -137,11 +172,11 @@ public class ExtensionManager var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken); using var doc = JsonDocument.Parse(manifestJson); - var id = doc.RootElement.GetProperty("name").GetString()?.ToLowerInvariant() ?? ""; + var id = NormalizeExtensionId(ReadString(doc.RootElement, "id", "name")); if (string.IsNullOrEmpty(id)) { - throw new InvalidDataException("Invalid name in manifest.json."); + throw new InvalidDataException("Invalid id/name in manifest.json."); } var targetFolder = Path.Combine(_extensionsDir, id); @@ -151,7 +186,7 @@ public class ExtensionManager } Directory.CreateDirectory(targetFolder); - ZipFile.ExtractToDirectory(tempFile, targetFolder, overwriteFiles: true); + CopyDirectory(packageRoot, targetFolder); File.Delete(tempFile); Directory.Delete(tempDir, true); @@ -226,6 +261,197 @@ public class ExtensionManager _logger.LogError(ex, "Failed to boot extension in folder {Path}", folderPath); } } + + public static List ParseStoreRegistry(string json, string repoUrl = "") + { + var items = new List(); + using var doc = JsonDocument.Parse(json); + + if (!TryGetRegistryItems(doc.RootElement, out var registryItems)) + { + return items; + } + + foreach (var ext in registryItems.EnumerateArray()) + { + if (ext.ValueKind != JsonValueKind.Object) + { + continue; + } + + var id = NormalizeExtensionId(ReadString(ext, "id", "name", "slug")); + var name = ReadString(ext, "name", "id", "slug"); + var displayName = ReadString(ext, "displayName", "display_name", "title", "label", "name"); + var downloadUrl = ReadString(ext, "downloadUrl", "download_url", "zipUrl", "zip_url", "archiveUrl", "archive_url", "packageUrl", "package_url", "url"); + + if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(downloadUrl)) + { + continue; + } + + if (string.IsNullOrWhiteSpace(name)) + { + name = id; + } + + if (string.IsNullOrWhiteSpace(displayName)) + { + displayName = name; + } + + var version = ReadString(ext, "version"); + items.Add(new StoreExtensionItem + { + Id = id, + Name = name, + DisplayName = displayName, + Description = ReadString(ext, "description", "summary"), + DownloadUrl = downloadUrl, + Version = string.IsNullOrWhiteSpace(version) ? "1.0.0" : version, + RepoUrl = repoUrl, + HomepageUrl = ReadString(ext, "homepage", "homepageUrl", "homepage_url", "repository", "repoUrl", "repo_url"), + Types = ReadStringList(ext, "types", "type", "capabilities", "capability") + }); + } + + return items + .GroupBy(item => item.Id, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .ToList(); + } + + private static bool TryGetRegistryItems(JsonElement root, out JsonElement items) + { + if (root.ValueKind == JsonValueKind.Array) + { + items = root; + return true; + } + + foreach (var key in new[] { "extensions", "items", "plugins", "packages" }) + { + if (root.TryGetProperty(key, out items) && items.ValueKind == JsonValueKind.Array) + { + return true; + } + } + + items = default; + return false; + } + + private static string ReadString(JsonElement element, params string[] keys) + { + foreach (var key in keys) + { + if (element.TryGetProperty(key, out var value)) + { + if (value.ValueKind == JsonValueKind.String) + { + return value.GetString() ?? string.Empty; + } + + if (value.ValueKind == JsonValueKind.Number || + value.ValueKind == JsonValueKind.True || + value.ValueKind == JsonValueKind.False) + { + return value.ToString(); + } + } + } + + return string.Empty; + } + + private static List ReadStringList(JsonElement element, params string[] keys) + { + foreach (var key in keys) + { + if (!element.TryGetProperty(key, out var value)) + { + continue; + } + + if (value.ValueKind == JsonValueKind.Array) + { + return value.EnumerateArray() + .Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() : item.ToString()) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Select(item => item!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + if (value.ValueKind == JsonValueKind.String) + { + return value.GetString()! + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + } + + return []; + } + + private static string NormalizeExtensionId(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return string.Empty; + } + + return Regex.Replace(raw.Trim().ToLowerInvariant(), "[^a-z0-9._-]", "-"); + } + + private static string ResolveExtensionPackageRoot(string extractedDirectory) + { + if (File.Exists(Path.Combine(extractedDirectory, "manifest.json")) && + File.Exists(Path.Combine(extractedDirectory, "index.js"))) + { + return extractedDirectory; + } + + var childDirectories = Directory.GetDirectories(extractedDirectory); + foreach (var childDirectory in childDirectories) + { + if (File.Exists(Path.Combine(childDirectory, "manifest.json")) && + File.Exists(Path.Combine(childDirectory, "index.js"))) + { + return childDirectory; + } + } + + return extractedDirectory; + } + + private static void CopyDirectory(string sourceDirectory, string targetDirectory) + { + foreach (var directory in Directory.GetDirectories(sourceDirectory, "*", SearchOption.AllDirectories)) + { + Directory.CreateDirectory(directory.Replace(sourceDirectory, targetDirectory)); + } + + foreach (var file in Directory.GetFiles(sourceDirectory, "*", SearchOption.AllDirectories)) + { + var targetFile = file.Replace(sourceDirectory, targetDirectory); + Directory.CreateDirectory(Path.GetDirectoryName(targetFile)!); + File.Copy(file, targetFile, overwrite: true); + } + } +} + +public class ExtensionStoreResponse +{ + public List Repositories { get; set; } = []; + public List Items { get; set; } = []; + public List Errors { get; set; } = []; +} + +public class ExtensionStoreError +{ + public string Repository { get; set; } = ""; + public string Message { get; set; } = ""; } public class StoreExtensionItem @@ -238,6 +464,8 @@ public class StoreExtensionItem public string Version { get; set; } = ""; public bool IsInstalled { get; set; } public string RepoUrl { get; set; } = ""; + public string HomepageUrl { get; set; } = ""; + public List Types { get; set; } = []; } public class ExtensionSandbox @@ -259,17 +487,37 @@ public class ExtensionSandbox using var doc = JsonDocument.Parse(manifestJson); var root = doc.RootElement; - Id = root.GetProperty("name").GetString() ?? ""; + Id = ReadManifestString(root, "id", "name"); 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) + DisplayName = ReadManifestString(root, "displayName", "display_name", "title", "name"); + if (string.IsNullOrWhiteSpace(DisplayName)) { - foreach (var t in typeEl.EnumerateArray()) + DisplayName = Id; + } + Description = ReadManifestString(root, "description", "summary"); + Version = ReadManifestString(root, "version"); + if (string.IsNullOrWhiteSpace(Version)) + { + Version = "1.0.0"; + } + + if (TryGetManifestProperty(root, out var typeEl, "types", "type", "capabilities", "capability")) + { + if (typeEl.ValueKind == JsonValueKind.Array) { - Types.Add(t.GetString() ?? ""); + foreach (var t in typeEl.EnumerateArray()) + { + var type = t.ValueKind == JsonValueKind.String ? t.GetString() : t.ToString(); + if (!string.IsNullOrWhiteSpace(type)) + { + Types.Add(type); + } + } + } + else if (typeEl.ValueKind == JsonValueKind.String) + { + Types.AddRange(typeEl.GetString()! + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); } } @@ -544,6 +792,30 @@ public class ExtensionSandbox if (str.Length >= 4 && int.TryParse(str.Substring(0, 4), out var yr)) return yr; return null; } + + private static string ReadManifestString(JsonElement element, params string[] keys) + { + if (!TryGetManifestProperty(element, out var value, keys)) + { + return string.Empty; + } + + return value.ValueKind == JsonValueKind.String ? value.GetString() ?? string.Empty : value.ToString(); + } + + private static bool TryGetManifestProperty(JsonElement element, out JsonElement value, params string[] keys) + { + foreach (var key in keys) + { + if (element.TryGetProperty(key, out value)) + { + return true; + } + } + + value = default; + return false; + } } public class ExtensionHostBridge diff --git a/allstarr/Services/Common/RuntimeEnvConfiguration.cs b/allstarr/Services/Common/RuntimeEnvConfiguration.cs index 0943dd9c..3bfca621 100644 --- a/allstarr/Services/Common/RuntimeEnvConfiguration.cs +++ b/allstarr/Services/Common/RuntimeEnvConfiguration.cs @@ -88,6 +88,14 @@ public static class RuntimeEnvConfiguration ["MUSICBRAINZ_USERNAME"] = ["MusicBrainz:Username"], ["MUSICBRAINZ_PASSWORD"] = ["MusicBrainz:Password"], + ["MULTI_PROVIDER_METADATA_ORDER"] = ["MULTI_PROVIDER_METADATA_ORDER"], + ["MULTI_PROVIDER_DOWNLOAD_ORDER"] = ["MULTI_PROVIDER_DOWNLOAD_ORDER"], + ["MULTI_PROVIDER_PLAYLIST_ORDER"] = ["MULTI_PROVIDER_PLAYLIST_ORDER"], + ["MULTI_PROVIDER_LYRICS_ORDER"] = ["MULTI_PROVIDER_LYRICS_ORDER"], + ["MULTI_PROVIDER_ENABLED_SEARCH"] = ["MULTI_PROVIDER_ENABLED_SEARCH"], + ["MULTI_PROVIDER_ENABLED_PLAYLIST"] = ["MULTI_PROVIDER_ENABLED_PLAYLIST"], + ["EXTENSION_REPOSITORIES"] = ["EXTENSION_REPOSITORIES"], + ["CACHE_SEARCH_RESULTS_MINUTES"] = ["Cache:SearchResultsMinutes"], ["CACHE_PLAYLIST_IMAGES_HOURS"] = ["Cache:PlaylistImagesHours"], ["CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS"] = ["Cache:SpotifyPlaylistItemsHours"], diff --git a/allstarr/wwwroot/css/base.css b/allstarr/wwwroot/css/base.css new file mode 100644 index 00000000..5d7fb4f2 --- /dev/null +++ b/allstarr/wwwroot/css/base.css @@ -0,0 +1,718 @@ +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +body { + background: var(--surface-page); + color: var(--text-primary); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.5; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-elevated); + color: var(--text-primary); + cursor: pointer; + min-height: 36px; + padding: 8px 12px; +} + +button:hover:not(:disabled) { + border-color: var(--border-strong); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +button.primary { + background: var(--accent); + border-color: var(--accent); + color: #071510; + font-weight: 700; +} + +button.primary:hover:not(:disabled) { + background: var(--accent-hover); +} + +button.ghost { + background: transparent; +} + +button.danger { + border-color: color-mix(in srgb, var(--error), transparent 45%); + color: var(--error); +} + +input, +select, +textarea { + width: 100%; + min-height: 38px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-elevated); + color: var(--text-primary); + padding: 8px 10px; +} + +textarea { + min-height: 96px; + resize: vertical; +} + +input[type="checkbox"] { + width: auto; + min-height: auto; +} + +a { + color: var(--accent); +} + +.app-loading, +.auth-screen { + min-height: 100vh; + display: grid; + place-items: center; + padding: var(--space-6); +} + +.auth-card { + width: min(440px, 100%); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-card); + box-shadow: var(--shadow); + padding: var(--space-6); +} + +.auth-card h1 { + margin: 0 0 var(--space-2); + font-size: 1.55rem; + letter-spacing: 0; +} + +.auth-card p { + margin: 0 0 var(--space-5); + color: var(--text-secondary); +} + +.form-stack { + display: grid; + gap: var(--space-4); +} + +.form-row { + display: grid; + gap: var(--space-2); +} + +.form-row label, +.field-label { + color: var(--text-secondary); + font-size: 0.84rem; + font-weight: 650; +} + +.inline-check { + display: flex; + align-items: center; + gap: var(--space-2); + color: var(--text-secondary); + font-size: 0.9rem; +} + +.auth-error, +.error-text { + color: var(--error); + min-height: 20px; + font-size: 0.88rem; +} + +.app-shell { + min-height: 100vh; + display: grid; + grid-template-columns: var(--rail-width) minmax(0, 1fr); +} + +.sidebar { + position: sticky; + top: 0; + height: 100vh; + border-right: 1px solid var(--border); + background: var(--surface-rail); + display: grid; + grid-template-rows: auto 1fr auto; + padding: var(--space-5); + gap: var(--space-5); +} + +.brand { + display: grid; + gap: var(--space-2); +} + +.brand-title { + color: var(--text-primary); + font-size: 1.35rem; + font-weight: 800; + text-decoration: none; +} + +.brand-subtitle { + color: var(--text-muted); + font-size: 0.82rem; +} + +.nav-list, +.subnav, +.chip-list, +.actions, +.row-actions { + display: flex; + gap: var(--space-2); +} + +.nav-list { + flex-direction: column; +} + +.nav-link { + display: flex; + justify-content: space-between; + align-items: center; + min-height: 40px; + border-radius: var(--radius); + color: var(--text-secondary); + text-decoration: none; + padding: 9px 10px; +} + +.nav-link.active, +.nav-link:hover { + background: var(--accent-soft); + color: var(--text-primary); +} + +.sidebar-footer { + display: grid; + gap: var(--space-3); + color: var(--text-secondary); + font-size: 0.88rem; +} + +.main-shell { + min-width: 0; + padding-bottom: calc(var(--now-playing-height) + var(--space-5)); +} + +.topbar { + position: sticky; + top: 0; + z-index: 4; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + min-height: 72px; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--surface-page), transparent 8%); + backdrop-filter: blur(14px); + padding: 0 var(--space-6); +} + +.topbar h1 { + margin: 0; + font-size: 1.1rem; + letter-spacing: 0; +} + +.topbar-meta { + color: var(--text-secondary); + font-size: 0.86rem; +} + +.mobile-menu { + display: none; +} + +.content { + width: min(1480px, 100%); + padding: var(--space-6); +} + +.view-stack { + display: grid; + gap: var(--space-6); +} + +.view-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-4); +} + +.view-header h2 { + margin: 0; + font-size: 1.65rem; + letter-spacing: 0; +} + +.view-header p { + margin: var(--space-1) 0 0; + color: var(--text-secondary); +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: var(--space-4); +} + +.wide-grid { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(320px, 0.7fr); + gap: var(--space-4); +} + +.card, +.panel, +.table-wrap { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-card); +} + +.card, +.panel { + padding: var(--space-5); +} + +.card h3, +.panel h3 { + margin: 0 0 var(--space-3); + font-size: 1rem; + letter-spacing: 0; +} + +.metric { + display: grid; + gap: var(--space-1); +} + +.metric-value { + font-size: 1.45rem; + font-weight: 800; +} + +.metric-label { + color: var(--text-secondary); + font-size: 0.82rem; +} + +.stat-list { + display: grid; + gap: var(--space-3); +} + +.stat-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-3); + padding-bottom: var(--space-3); + border-bottom: 1px solid var(--border); +} + +.stat-row:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.muted { + color: var(--text-secondary); +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.chip, +.status-chip { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 26px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--text-secondary); + background: var(--surface-elevated); + padding: 3px 9px; + font-size: 0.78rem; + font-weight: 700; + white-space: nowrap; +} + +.status-chip.configured, +.status-chip.healthy, +.chip.success { + color: var(--success); + background: color-mix(in srgb, var(--success), transparent 86%); + border-color: color-mix(in srgb, var(--success), transparent 58%); +} + +.status-chip.needs_config, +.status-chip.warning, +.chip.warning { + color: var(--warning); + background: color-mix(in srgb, var(--warning), transparent 87%); + border-color: color-mix(in srgb, var(--warning), transparent 58%); +} + +.status-chip.error, +.chip.error { + color: var(--error); + background: color-mix(in srgb, var(--error), transparent 88%); + border-color: color-mix(in srgb, var(--error), transparent 58%); +} + +.subnav { + flex-wrap: wrap; +} + +.subnav a { + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-secondary); + text-decoration: none; + padding: 8px 10px; +} + +.subnav a.active, +.subnav a:hover { + color: var(--text-primary); + background: var(--accent-soft); +} + +.table-wrap { + overflow: auto; +} + +table { + width: 100%; + border-collapse: collapse; + min-width: 760px; +} + +th, +td { + border-bottom: 1px solid var(--border); + padding: 12px 14px; + text-align: left; + vertical-align: top; +} + +th { + color: var(--text-secondary); + font-size: 0.78rem; + text-transform: uppercase; +} + +tr:last-child td { + border-bottom: 0; +} + +.track { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.art { + width: 44px; + height: 44px; + border-radius: var(--radius); + object-fit: cover; + background: var(--surface-elevated); +} + +.actions, +.row-actions { + align-items: center; + flex-wrap: wrap; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: var(--space-3); +} + +.toolbar .form-row { + min-width: 180px; +} + +.provider-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: var(--space-4); +} + +.provider-card { + display: grid; + gap: var(--space-4); + min-height: 180px; +} + +.provider-head { + display: flex; + justify-content: space-between; + gap: var(--space-3); +} + +.provider-title { + display: grid; + gap: 2px; +} + +.provider-title strong { + font-size: 1rem; +} + +.provider-title span { + color: var(--text-secondary); + font-size: 0.84rem; +} + +.config-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: var(--space-4); +} + +.config-field { + display: grid; + gap: var(--space-2); +} + +.field-heading { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-2); +} + +.restart-mark { + color: var(--warning); + font-size: 0.75rem; + font-weight: 800; +} + +.priority-list { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.priority-item { + display: inline-flex; + align-items: center; + gap: var(--space-2); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-elevated); + padding: 6px 8px; +} + +.priority-item button { + min-height: 26px; + padding: 2px 7px; +} + +.activity-list { + display: grid; + gap: var(--space-3); +} + +.activity-item { + display: grid; + gap: var(--space-2); + border-bottom: 1px solid var(--border); + padding-bottom: var(--space-3); +} + +.activity-item:last-child { + border-bottom: 0; + padding-bottom: 0; +} + +.progress { + height: 8px; + border-radius: 999px; + background: var(--surface-elevated); + overflow: hidden; +} + +.progress span { + display: block; + height: 100%; + width: var(--progress, 0%); + background: var(--accent); +} + +.now-playing { + position: fixed; + right: var(--space-5); + bottom: var(--space-5); + left: calc(var(--rail-width) + var(--space-5)); + z-index: 5; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(180px, 280px); + align-items: center; + gap: var(--space-4); + min-height: var(--now-playing-height); + border: 1px solid var(--border); + border-radius: var(--radius); + background: color-mix(in srgb, var(--surface-card), transparent 5%); + box-shadow: var(--shadow); + padding: var(--space-4); + backdrop-filter: blur(16px); +} + +.now-title { + font-weight: 800; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.now-meta { + color: var(--text-secondary); + font-size: 0.84rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.restart-bar { + position: fixed; + right: var(--space-5); + bottom: calc(var(--now-playing-height) + var(--space-6) + var(--space-4)); + z-index: 6; + display: flex; + align-items: center; + gap: var(--space-3); + border: 1px solid color-mix(in srgb, var(--warning), transparent 45%); + border-radius: var(--radius); + background: color-mix(in srgb, var(--surface-card), var(--warning) 10%); + box-shadow: var(--shadow); + padding: var(--space-3); +} + +.toast-stack { + position: fixed; + right: var(--space-5); + top: 86px; + z-index: 10; + display: grid; + gap: var(--space-2); + width: min(420px, calc(100vw - 32px)); +} + +.toast { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-card); + box-shadow: var(--shadow); + padding: var(--space-3); +} + +.toast.error { + border-color: color-mix(in srgb, var(--error), transparent 40%); +} + +.toast.success { + border-color: color-mix(in srgb, var(--success), transparent 40%); +} + +.empty { + border: 1px dashed var(--border); + border-radius: var(--radius); + color: var(--text-secondary); + padding: var(--space-5); + text-align: center; +} + +.hidden { + display: none !important; +} + +@media (max-width: 960px) { + .app-shell { + grid-template-columns: 1fr; + } + + .sidebar { + position: fixed; + inset: 0 auto 0 0; + z-index: 8; + width: min(var(--rail-width), calc(100vw - 56px)); + transform: translateX(-100%); + transition: transform 0.2s ease; + } + + .sidebar.open { + transform: translateX(0); + } + + .mobile-menu { + display: inline-flex; + } + + .content, + .topbar { + padding-left: var(--space-4); + padding-right: var(--space-4); + } + + .wide-grid { + grid-template-columns: 1fr; + } + + .view-header { + display: grid; + } + + .now-playing { + left: var(--space-3); + right: var(--space-3); + bottom: var(--space-3); + grid-template-columns: 1fr; + } + + .restart-bar { + left: var(--space-3); + right: var(--space-3); + bottom: calc(var(--now-playing-height) + 28px); + } +} diff --git a/allstarr/wwwroot/css/tokens.css b/allstarr/wwwroot/css/tokens.css new file mode 100644 index 00000000..885161be --- /dev/null +++ b/allstarr/wwwroot/css/tokens.css @@ -0,0 +1,80 @@ +:root { + color-scheme: dark; + --surface-page: #101010; + --surface-rail: #171717; + --surface-card: #202020; + --surface-elevated: #2a2a2a; + --surface-subtle: #181c1a; + --text-primary: #f7f4ed; + --text-secondary: #b8b0a3; + --text-muted: #847d73; + --accent: #38b48b; + --accent-hover: #4cc99f; + --accent-soft: rgba(56, 180, 139, 0.16); + --success: #5fbf7a; + --warning: #d9a441; + --error: #e56868; + --info: #6aa6d9; + --border: #363636; + --border-strong: #4b4b4b; + --shadow: 0 16px 48px rgba(0, 0, 0, 0.32); + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --radius: 8px; + --rail-width: 264px; + --now-playing-height: 76px; +} + +:root[data-theme="light"] { + color-scheme: light; + --surface-page: #f7f5ef; + --surface-rail: #ece8de; + --surface-card: #fffdf8; + --surface-elevated: #f1eee6; + --surface-subtle: #eef4ef; + --text-primary: #20201d; + --text-secondary: #5f5a52; + --text-muted: #837b70; + --accent: #187b63; + --accent-hover: #126750; + --accent-soft: rgba(24, 123, 99, 0.13); + --success: #257a47; + --warning: #9b6a1d; + --error: #bd3e42; + --info: #376f97; + --border: #ddd7c9; + --border-strong: #c6beac; + --shadow: 0 16px 44px rgba(80, 72, 58, 0.16); +} + +:root[data-theme="dark"] { + color-scheme: dark; +} + +@media (prefers-color-scheme: light) { + :root:not([data-theme="dark"]) { + color-scheme: light; + --surface-page: #f7f5ef; + --surface-rail: #ece8de; + --surface-card: #fffdf8; + --surface-elevated: #f1eee6; + --surface-subtle: #eef4ef; + --text-primary: #20201d; + --text-secondary: #5f5a52; + --text-muted: #837b70; + --accent: #187b63; + --accent-hover: #126750; + --accent-soft: rgba(24, 123, 99, 0.13); + --success: #257a47; + --warning: #9b6a1d; + --error: #bd3e42; + --info: #376f97; + --border: #ddd7c9; + --border-strong: #c6beac; + --shadow: 0 16px 44px rgba(80, 72, 58, 0.16); + } +} diff --git a/allstarr/wwwroot/index.html b/allstarr/wwwroot/index.html index 5744d705..7f70700b 100644 --- a/allstarr/wwwroot/index.html +++ b/allstarr/wwwroot/index.html @@ -1,1690 +1,18 @@ - + - - - Allstarr Dashboard - + + + + Allstarr + + - -
- ⚠️ Configuration changed. Restart required to apply changes. - - -
- -
-
-

Sign In With Jellyfin

-

Use your Jellyfin account to access the local Allstarr admin UI.

-
- - - - - - - - Use only on a device you trust. - - - -
-
- -
-

- If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider - supporting its development -

- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
-
-

Restarting Container

-

Applying configuration changes...

-
- - + + + diff --git a/allstarr/wwwroot/js/webui.js b/allstarr/wwwroot/js/webui.js new file mode 100644 index 00000000..06562e84 --- /dev/null +++ b/allstarr/wwwroot/js/webui.js @@ -0,0 +1,1602 @@ +import { LitElement, html, nothing } from "https://cdn.jsdelivr.net/npm/lit@3/+esm"; + +const THEME_KEY = "allstarr-theme"; +const DEFAULT_ROUTE = "/home"; + +function normalizeRoute(hash = window.location.hash) { + const route = hash.replace(/^#/, "") || DEFAULT_ROUTE; + return route.startsWith("/") ? route : `/${route}`; +} + +function routeParts(route) { + return route.split("/").filter(Boolean); +} + +function getPathValue(source, path, fallback = "") { + if (!source || !path) { + return fallback; + } + + return path.split(".").reduce((current, key) => { + if (current && Object.prototype.hasOwnProperty.call(current, key)) { + return current[key]; + } + + return undefined; + }, source) ?? fallback; +} + +function setPathValue(source, path, value) { + if (!source || !path) { + return; + } + + const parts = path.split("."); + let current = source; + for (const part of parts.slice(0, -1)) { + if (!current[part] || typeof current[part] !== "object") { + current[part] = {}; + } + current = current[part]; + } + current[parts.at(-1)] = value; +} + +function display(value, fallback = "-") { + if (value === null || value === undefined || value === "") { + return fallback; + } + return String(value); +} + +function titleCase(value) { + return display(value) + .replace(/[_-]+/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()); +} + +function formatDate(value) { + if (!value) { + return "-"; + } + + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? "-" : parsed.toLocaleString(); +} + +function percent(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return 0; + } + return Math.max(0, Math.min(100, numeric * (numeric <= 1 ? 100 : 1))); +} + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function parseBoolValue(value) { + if (typeof value === "boolean") { + return value; + } + if (typeof value === "number") { + return value !== 0; + } + if (typeof value === "string") { + return ["true", "1", "yes", "on", "enabled"].includes(value.trim().toLowerCase()); + } + return false; +} + +async function readErrorMessage(response, fallback) { + try { + const data = await response.json(); + return data.error || data.message || fallback; + } catch { + return fallback; + } +} + +async function requestJson(url, options = {}, fallback = "Request failed") { + const response = await fetch(url, { + credentials: "same-origin", + ...options, + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, fallback)); + } + + return response.json(); +} + +async function requestBlob(url, options = {}, fallback = "Request failed") { + const response = await fetch(url, { + credentials: "same-origin", + ...options, + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, fallback)); + } + + return response.blob(); +} + +function jsonBody(payload, method = "POST") { + return { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }; +} + +const API = { + me: () => requestJson("/api/admin/auth/me", { cache: "no-store" }, "Authentication required"), + login: (username, password, rememberMe) => + requestJson("/api/admin/auth/login", jsonBody({ username, password, rememberMe }), "Authentication failed"), + logout: () => requestJson("/api/admin/auth/logout", { method: "POST" }, "Logout failed"), + schema: () => requestJson("/api/admin/ui/schema", { cache: "no-store" }, "Failed to load UI schema"), + status: () => requestJson("/api/admin/status", { cache: "no-store" }, "Failed to load status"), + config: () => requestJson("/api/admin/config", { cache: "no-store" }, "Failed to load config"), + updateConfig: (key, value) => + requestJson("/api/admin/config", jsonBody({ updates: { [key]: String(value) } }), "Failed to save setting"), + clearCache: () => requestJson("/api/admin/cache/clear", { method: "POST" }, "Failed to clear cache"), + restart: () => requestJson("/api/admin/restart", { method: "POST" }, "Failed to restart"), + exportEnv: () => requestBlob("/api/admin/export-env", {}, "Failed to export .env"), + importEnv: (file) => { + const data = new FormData(); + data.append("file", file); + return requestJson("/api/admin/import-env", { method: "POST", body: data }, "Failed to import .env"); + }, + playlists: (refresh = false) => + requestJson(`/api/admin/playlists${refresh ? "?refresh=true" : ""}`, {}, "Failed to load playlists"), + playlistTracks: (name) => + requestJson(`/api/admin/playlists/${encodeURIComponent(name)}/tracks`, {}, "Failed to load playlist tracks"), + refreshPlaylists: () => requestJson("/api/admin/playlists/refresh", { method: "POST" }, "Failed to refresh playlists"), + refreshPlaylist: (name) => + requestJson(`/api/admin/playlists/${encodeURIComponent(name)}/refresh`, { method: "POST" }, "Failed to refresh playlist"), + matchPlaylist: (name) => + requestJson(`/api/admin/playlists/${encodeURIComponent(name)}/match`, { method: "POST" }, "Failed to match playlist"), + clearPlaylistCache: (name) => + requestJson(`/api/admin/playlists/${encodeURIComponent(name)}/clear-cache`, { method: "POST" }, "Failed to clear playlist cache"), + addPlaylist: (name, spotifyId, localTracksPosition = "first") => + requestJson("/api/admin/playlists", jsonBody({ name, spotifyId, localTracksPosition }), "Failed to add playlist"), + removePlaylist: (name) => + requestJson(`/api/admin/playlists/${encodeURIComponent(name)}`, { method: "DELETE" }, "Failed to remove playlist"), + jellyfinUsers: () => requestJson("/api/admin/jellyfin/users", {}, "Failed to load Jellyfin users"), + jellyfinPlaylists: (userId = "", includeStats = true) => { + const params = new URLSearchParams({ includeStats: String(includeStats) }); + if (userId) { + params.set("userId", userId); + } + return requestJson(`/api/admin/jellyfin/playlists?${params}`, {}, "Failed to load backend playlists"); + }, + spotifyUserPlaylists: (userId = "") => { + const suffix = userId ? `?userId=${encodeURIComponent(userId)}` : ""; + return requestJson(`/api/admin/spotify/user-playlists${suffix}`, {}, "Failed to load Spotify playlists"); + }, + linkPlaylist: (jellyfinId, spotifyPlaylistId, syncSchedule, userId = "") => { + const payload = { spotifyPlaylistId, syncSchedule }; + if (userId) { + payload.userId = userId; + } + return requestJson(`/api/admin/jellyfin/playlists/${encodeURIComponent(jellyfinId)}/link`, jsonBody(payload), "Failed to link playlist"); + }, + unlinkPlaylist: (jellyfinId) => + requestJson(`/api/admin/jellyfin/playlists/${encodeURIComponent(jellyfinId)}/unlink`, { method: "DELETE" }, "Failed to unlink playlist"), + downloads: () => requestJson("/api/admin/downloads", {}, "Failed to load downloads"), + deleteDownload: (path) => + requestJson(`/api/admin/downloads?path=${encodeURIComponent(path)}`, { method: "DELETE" }, "Failed to delete download"), + deleteAllDownloads: () => requestJson("/api/admin/downloads/all", { method: "DELETE" }, "Failed to delete downloads"), + endpointUsage: (top = 50) => + requestJson(`/api/admin/debug/endpoint-usage?top=${top}`, {}, "Failed to load endpoint usage"), + clearEndpointUsage: () => requestJson("/api/admin/debug/endpoint-usage", { method: "DELETE" }, "Failed to clear endpoint usage"), + queue: () => requestJson("/api/admin/downloads/queue", {}, "Failed to load queue"), + mappings: (params = {}) => requestJson(`/api/admin/spotify/mappings?${new URLSearchParams(params)}`, {}, "Failed to load mappings"), + saveMapping: (payload) => requestJson("/api/admin/spotify/mappings", jsonBody(payload), "Failed to save mapping"), + deleteMapping: (spotifyId, provider = "") => { + const suffix = provider ? `?provider=${encodeURIComponent(provider)}` : ""; + return requestJson(`/api/admin/spotify/mappings/${encodeURIComponent(spotifyId)}${suffix}`, { method: "DELETE" }, "Failed to delete mapping"); + }, + externalPlaylistSearch: (query, provider, limit = 20) => { + const params = new URLSearchParams({ query, provider, limit: String(limit) }); + return requestJson(`/api/admin/external/playlists/search?${params}`, {}, "Failed to search playlists"); + }, + externalPlaylistTracks: (provider, externalId, limit = 50) => + requestJson(`/api/admin/external/playlists/${encodeURIComponent(provider)}/${encodeURIComponent(externalId)}/tracks?limit=${limit}`, {}, "Failed to load external playlist tracks"), + extensionStore: () => requestJson("/api/admin/extensions/store", {}, "Failed to load extension store"), + installedExtensions: () => requestJson("/api/admin/extensions/installed", {}, "Failed to load installed extensions"), + installExtension: (item) => + requestJson("/api/admin/extensions/install", jsonBody({ id: item.id || item.Id, downloadUrl: item.downloadUrl || item.DownloadUrl || "" }), "Failed to install extension"), + uninstallExtension: (id) => + requestJson(`/api/admin/extensions/uninstall/${encodeURIComponent(id)}`, { method: "DELETE" }, "Failed to uninstall extension"), + scrobblingStatus: () => requestJson("/api/admin/scrobbling/status", {}, "Failed to load scrobbling"), + updateLocalTracksScrobbling: (enabled) => + requestJson("/api/admin/scrobbling/local-tracks/update", jsonBody({ enabled }), "Failed to update local scrobbling"), + testLastFm: () => requestJson("/api/admin/scrobbling/lastfm/test", { method: "POST" }, "Failed to test Last.fm"), + validateListenBrainz: (userToken) => + requestJson("/api/admin/scrobbling/listenbrainz/validate", jsonBody({ userToken }), "Failed to validate ListenBrainz"), + testListenBrainz: () => requestJson("/api/admin/scrobbling/listenbrainz/test", { method: "POST" }, "Failed to test ListenBrainz"), +}; + +class ThemeManager { + static apply(theme) { + if (theme === "system") { + document.documentElement.removeAttribute("data-theme"); + } else { + document.documentElement.dataset.theme = theme; + } + localStorage.setItem(THEME_KEY, theme); + } + + static current() { + return localStorage.getItem(THEME_KEY) || "system"; + } +} + +class AllstarrApp extends LitElement { + static properties = { + authenticated: { type: Boolean }, + loading: { type: Boolean }, + route: { type: String }, + navOpen: { type: Boolean }, + session: { state: true }, + schema: { state: true }, + config: { state: true }, + status: { state: true }, + theme: { state: true }, + loginError: { state: true }, + restartKeys: { state: true }, + toasts: { state: true }, + activity: { state: true }, + playlists: { state: true }, + jellyfinPlaylists: { state: true }, + spotifyPlaylists: { state: true }, + jellyfinUsers: { state: true }, + downloads: { state: true }, + endpointUsage: { state: true }, + mappings: { state: true }, + externalPlaylists: { state: true }, + externalPlaylistTracks: { state: true }, + extensionStore: { state: true }, + installedExtensions: { state: true }, + scrobbling: { state: true }, + }; + + constructor() { + super(); + this.authenticated = false; + this.loading = true; + this.route = normalizeRoute(); + this.navOpen = false; + this.session = null; + this.schema = null; + this.config = null; + this.status = null; + this.theme = ThemeManager.current(); + this.loginError = ""; + this.restartKeys = new Set(); + this.toasts = []; + this.activity = []; + this.playlists = null; + this.jellyfinPlaylists = null; + this.spotifyPlaylists = null; + this.jellyfinUsers = []; + this.downloads = null; + this.endpointUsage = null; + this.mappings = null; + this.externalPlaylists = null; + this.externalPlaylistTracks = new Map(); + this.extensionStore = null; + this.installedExtensions = null; + this.scrobbling = null; + this.linkSelections = new Map(); + this.mappingFilters = { page: 1, pageSize: 50, enrichMetadata: true, targetType: "all", source: "all", search: "" }; + this.externalPlaylistProvider = "deezer"; + this.externalPlaylistQuery = ""; + this.activitySource = null; + this.routeLoadKey = ""; + } + + createRenderRoot() { + return this; + } + + connectedCallback() { + super.connectedCallback(); + ThemeManager.apply(this.theme); + this.onHashChange = () => { + this.route = normalizeRoute(); + this.navOpen = false; + this.loadForRoute(); + }; + window.addEventListener("hashchange", this.onHashChange); + this.bootstrap(); + } + + disconnectedCallback() { + window.removeEventListener("hashchange", this.onHashChange); + this.stopActivityStream(); + super.disconnectedCallback(); + } + + async bootstrap() { + this.loading = true; + try { + const authState = await API.me(); + if (!(authState.authenticated || authState.Authenticated)) { + this.authenticated = false; + this.session = null; + return; + } + + this.session = authState.user || authState.User; + this.authenticated = true; + await Promise.all([this.loadSchema(), this.loadConfig(), this.loadStatus()]); + this.startActivityStream(); + await this.loadForRoute(); + } catch (error) { + this.authenticated = false; + this.session = null; + if (!String(error.message).includes("Authentication")) { + this.toast(error.message, "error"); + } + } finally { + this.loading = false; + } + } + + async loadSchema() { + this.schema = await API.schema(); + } + + async loadConfig() { + this.config = await API.config(); + } + + async loadStatus() { + this.status = await API.status(); + } + + async loadForRoute(force = false) { + if (!this.authenticated) { + return; + } + + const routeKey = `${this.route}`; + if (!force && routeKey === this.routeLoadKey) { + return; + } + this.routeLoadKey = routeKey; + + const [zone, sub] = routeParts(this.route); + try { + if (zone === "library") { + if (sub === "link") { + await this.loadLinkData(); + } else if (sub === "injected") { + await this.loadPlaylists(); + } else if (sub === "mappings") { + await this.loadMappings(); + } else if (sub === "missing" || sub === "migration") { + await this.loadMigrationData(); + } else if (sub === "kept") { + await this.loadDownloads(); + } + } else if (zone === "sources") { + await this.loadInstalledExtensions(); + } else if (zone === "activity") { + await Promise.all([this.loadEndpointUsage(), this.loadScrobbling(), this.loadQueue()]); + } + } catch (error) { + this.toast(error.message, "error"); + } + } + + startActivityStream() { + this.stopActivityStream(); + this.activitySource = new EventSource("/api/admin/downloads/activity"); + this.activitySource.onmessage = (event) => { + try { + this.activity = JSON.parse(event.data); + } catch { + this.activity = []; + } + }; + this.activitySource.onerror = () => { + this.stopActivityStream(); + }; + } + + stopActivityStream() { + if (this.activitySource) { + this.activitySource.close(); + this.activitySource = null; + } + } + + async login(event) { + event.preventDefault(); + const form = event.currentTarget; + const data = new FormData(form); + this.loginError = ""; + try { + await API.login(data.get("username"), data.get("password"), data.get("remember") === "on"); + await this.bootstrap(); + } catch (error) { + this.loginError = error.message; + } + } + + async logout() { + try { + await API.logout(); + } finally { + this.authenticated = false; + this.session = null; + this.stopActivityStream(); + } + } + + setTheme(theme) { + this.theme = theme; + ThemeManager.apply(theme); + } + + toast(message, type = "success") { + const id = crypto.randomUUID?.() || String(Date.now()); + this.toasts = [...this.toasts, { id, message, type }]; + window.setTimeout(() => { + this.toasts = this.toasts.filter((toast) => toast.id !== id); + }, 4200); + } + + navigate(path) { + window.location.hash = path; + } + + async saveField(field, value) { + if (field.sensitive && !value) { + return; + } + + await API.updateConfig(field.key, value); + if (field.valuePath) { + const nextConfig = structuredClone(this.config || {}); + setPathValue(nextConfig, field.valuePath, field.type === "toggle" ? value === "true" : value); + this.config = nextConfig; + } + if (field.requiresRestart) { + this.restartKeys = new Set([...this.restartKeys, field.key]); + } + this.toast(`${field.label} saved`); + } + + async savePriority(group, providers) { + await API.updateConfig(group.envKey, providers.join(",")); + this.schema = { + ...this.schema, + priorityGroups: this.schema.priorityGroups.map((item) => + item.id === group.id ? { ...item, providers } : item, + ), + }; + this.restartKeys = new Set([...this.restartKeys, group.envKey]); + this.toast(`${group.label} saved`); + } + + async loadPlaylists(refresh = false) { + this.playlists = await API.playlists(refresh); + } + + async loadLinkData() { + const userId = this.renderRoot?.querySelector?.("#jellyfin-user-filter")?.value || ""; + const requests = [ + API.jellyfinPlaylists(userId, true), + API.spotifyUserPlaylists(userId).catch((error) => { + this.toast(error.message, "error"); + return { playlists: [] }; + }), + ]; + if (this.session?.isAdministrator || this.session?.IsAdministrator) { + requests.push(API.jellyfinUsers().catch(() => [])); + } + const [jellyfin, spotify, users = []] = await Promise.all(requests); + this.jellyfinPlaylists = jellyfin; + this.spotifyPlaylists = spotify; + this.jellyfinUsers = Array.isArray(users) ? users : users.users || users.Users || []; + } + + async loadDownloads() { + this.downloads = await API.downloads(); + } + + async loadQueue() { + this.activity = await API.queue(); + } + + async loadEndpointUsage() { + this.endpointUsage = await API.endpointUsage(50); + } + + async loadMappings() { + this.mappings = await API.mappings(this.mappingFilters); + } + + async loadMigrationData() { + await this.loadPlaylists(); + } + + async loadInstalledExtensions() { + this.installedExtensions = await API.installedExtensions(); + } + + async loadExtensionStore() { + this.extensionStore = await API.extensionStore(); + } + + async loadScrobbling() { + this.scrobbling = await API.scrobblingStatus(); + } + + render() { + if (this.loading) { + return html`
Loading Allstarr
`; + } + + if (!this.authenticated) { + return this.renderAuth(); + } + + return html` +
+ ${this.renderSidebar()} +
+ ${this.renderTopbar()} +
${this.renderRoute()}
+
+
+ ${this.renderRestartBar()} + ${this.renderNowPlaying()} + ${this.renderToasts()} + `; + } + + renderAuth() { + return html` +
+
+

Allstarr

+

Sign in with a Jellyfin account to manage this server.

+
+
+ + +
+
+ + +
+ + + +
+
+
+ `; + } + + renderSidebar() { + const routes = asArray(this.schema?.routes); + return html` + + `; + } + + isRouteActive(path) { + const routeZone = routeParts(this.route)[0] || "home"; + const pathZone = routeParts(path.replace("#", ""))[0] || "home"; + return routeZone === pathZone; + } + + renderTopbar() { + const [zone, sub] = routeParts(this.route); + return html` +
+
+ +

${titleCase(zone || "home")}${sub ? html` / ${titleCase(sub)}` : nothing}

+
${display(this.status?.spotify?.authStatus || this.status?.Spotify?.AuthStatus, "status unknown")}
+
+
+ + +
+
+ `; + } + + renderRoute() { + const [zone] = routeParts(this.route); + if (zone === "library") { + return this.renderLibrary(); + } + if (zone === "sources") { + return this.renderSources(); + } + if (zone === "activity") { + return this.renderActivity(); + } + if (zone === "settings") { + return this.renderSettings(); + } + return this.renderHome(); + } + + renderHome() { + const spotify = this.status?.spotify || this.status?.Spotify || {}; + const spotifyImport = this.status?.spotifyImport || this.status?.SpotifyImport || {}; + const providerCards = asArray(this.schema?.providers).filter((provider) => + ["squidwtf", "applemusic", "deezer", "qobuz"].includes(provider.id), + ); + + return html` +
+
+
+

Home

+

Runtime state, provider readiness, and current activity.

+
+
+ + + +
+
+ +
+
+ Backend + ${display(this.status?.backendType || this.config?.backendType)} +
+
+ Spotify + ${titleCase(spotify.authStatus || "unknown")} +
+
+ Injected playlists + ${display(spotifyImport.playlistCount ?? this.config?.spotifyImport?.playlists?.length ?? 0)} +
+
+ Active tasks + ${this.activity.length} +
+
+ +
+
+

Setup

+
+ ${this.renderSetupStep("Backend configured", Boolean(this.config?.jellyfin?.url || this.config?.subsonic?.url))} + ${this.renderSetupStep("Spotify cookie present", Boolean(spotify.hasCookie || spotify.HasCookie))} + ${this.renderSetupStep("Download source selected", Boolean(this.config?.musicService))} + ${this.renderSetupStep("Playlist sync enabled", Boolean(this.config?.spotifyImport?.enabled))} +
+
+
+

Provider health

+
+ ${providerCards.map((provider) => html` +
+ ${provider.name} + ${titleCase(provider.status)} +
+ `)} +
+
+
+ +
+

Activity feed

+ ${this.renderActivityList(this.activity.slice(0, 8))} +
+
+ `; + } + + renderSetupStep(label, complete) { + return html` +
+ ${label} + ${complete ? "Ready" : "Needs setup"} +
+ `; + } + + renderLibrary() { + const [, sub = "overview"] = routeParts(this.route); + return html` +
+
+
+

Library

+

Backend playlists, injected playlists, mappings, missing tracks, and kept files.

+
+
+ ${this.renderLibraryNav(sub)} + ${sub === "link" ? this.renderLinkPlaylists() : + sub === "injected" ? this.renderInjectedPlaylists() : + sub === "mappings" ? this.renderMappings() : + sub === "missing" ? this.renderMissingTracks() : + sub === "migration" ? this.renderSongMigration() : + sub === "kept" ? this.renderKeptDownloads() : + sub === "external" ? this.renderExternalPlaylistExplorer() : + this.renderLibraryOverview()} +
+ `; + } + + renderLibraryNav(active) { + const items = [ + ["overview", "Overview"], + ["link", "Link playlists"], + ["injected", "Injected"], + ["mappings", "Mappings"], + ["missing", "Missing"], + ["migration", "Migration"], + ["kept", "Kept"], + ["external", "External playlists"], + ]; + return html` + + `; + } + + renderLibraryOverview() { + return html` +
+ + + + +
+ `; + } + + renderLinkPlaylists() { + const playlists = asArray(this.jellyfinPlaylists?.playlists || this.jellyfinPlaylists?.Playlists); + const spotifyPlaylists = asArray(this.spotifyPlaylists?.playlists || this.spotifyPlaylists?.Playlists || this.spotifyPlaylists); + + return html` +
+
+ ${this.jellyfinUsers.length ? html` +
+ + +
+ ` : nothing} + +
+
+
+ + + + + + + + + + + + ${playlists.length ? playlists.map((playlist) => this.renderLinkPlaylistRow(playlist, spotifyPlaylists)) : html` + + `} + +
Backend playlistTracksStatusSpotify source
No backend playlists loaded.
+
+ `; + } + + renderLinkPlaylistRow(playlist, spotifyPlaylists) { + const id = playlist.id || playlist.Id; + const name = playlist.name || playlist.Name; + const linked = playlist.isConfigured || playlist.IsConfigured; + const selection = this.linkSelections.get(id) || ""; + return html` + + ${name} + ${display(playlist.trackCount ?? playlist.TrackCount ?? playlist.childCount ?? playlist.ChildCount)} + ${linked ? "Linked" : "Unlinked"} + + ${linked ? html`${display(playlist.linkedSpotifyId || playlist.LinkedSpotifyId)}` : html` + + `} + + + ${linked ? html` + + ` : html` + + `} + + + `; + } + + renderInjectedPlaylists() { + const playlists = asArray(this.playlists?.playlists || this.playlists?.Playlists); + return html` +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+ + + + ${playlists.length ? playlists.map((playlist) => html` + + + + + + + + + `) : html``} + +
NameTracksLocalExternalSchedule
${playlist.name}
${playlist.id}
${display(playlist.trackCount)}${display(playlist.localTracks)}${display(playlist.externalTracks)}${display(playlist.syncSchedule)} + + + + +
No injected playlists loaded.
+
+ `; + } + + addInjectedPlaylist = async (event) => { + event.preventDefault(); + const data = new FormData(event.currentTarget); + await API.addPlaylist(data.get("name"), data.get("spotifyId")); + event.currentTarget.reset(); + await this.loadPlaylists(true); + this.restartKeys = new Set([...this.restartKeys, "SPOTIFY_IMPORT_PLAYLISTS"]); + this.toast("Playlist added"); + }; + + renderMappings() { + const mappings = asArray(this.mappings?.mappings || this.mappings?.Mappings); + const stats = this.mappings?.stats || this.mappings?.Stats || {}; + const pagination = this.mappings?.pagination || this.mappings?.Pagination || {}; + + return html` +
+
Total${display(stats.TotalMappings ?? stats.totalMappings ?? 0)}
+
Local${display(stats.LocalMappings ?? stats.localMappings ?? 0)}
+
External${display(stats.ExternalMappings ?? stats.externalMappings ?? 0)}
+
Manual${display(stats.ManualMappings ?? stats.manualMappings ?? 0)}
+
+
+
+
+ + { this.mappingFilters.search = event.target.value; }}> +
+
+ + +
+ +
+
+
+

Manual mapping

+
+
+
+ + +
+
+
+
+ +
+
+
+ + + + ${mappings.length ? mappings.map((mapping) => this.renderMappingRow(mapping)) : html` + + `} + +
TrackSpotify IDTargetSourceCreated
No mappings found.
+
+
+
+ + Page ${display(pagination.page ?? 1)} of ${display(pagination.totalPages ?? 1)} + +
+
+ `; + } + + renderMappingRow(mapping) { + const metadata = mapping.Metadata || mapping.metadata || {}; + const spotifyId = mapping.SpotifyId || mapping.spotifyId; + const targetType = mapping.TargetType || mapping.targetType; + const source = mapping.Source || mapping.source; + return html` + + +
+ +
+ ${display(metadata.Title || metadata.title, "Unknown track")} +
${display(metadata.Artist || metadata.artist, "Unknown artist")}
+
+
+ + ${spotifyId} + ${this.renderMappingTargets(mapping)} + ${display(source)} + ${formatDate(mapping.CreatedAt || mapping.createdAt)} + + + `; + } + + renderMappingTargets(mapping) { + const targets = []; + if ((mapping.TargetType || mapping.targetType) === "local" && (mapping.LocalId || mapping.localId)) { + targets.push(["local", mapping.LocalId || mapping.localId]); + } + for (const item of asArray(mapping.ExternalMappings || mapping.externalMappings)) { + targets.push([item.Provider || item.provider, item.ExternalId || item.externalId]); + } + if (mapping.ExternalProvider || mapping.externalProvider) { + targets.push([mapping.ExternalProvider || mapping.externalProvider, mapping.ExternalId || mapping.externalId]); + } + return html`${targets.length ? targets.map(([label, value]) => html`${label}: ${value}`) : html`None`}`; + } + + saveManualMapping = async (event) => { + event.preventDefault(); + const data = new FormData(event.currentTarget); + const targetType = data.get("targetType"); + const payload = { + spotifyId: data.get("spotifyId"), + targetType, + localId: data.get("localId") || null, + externalProvider: data.get("externalProvider") || null, + externalId: data.get("externalId") || null, + }; + await API.saveMapping(payload); + event.currentTarget.reset(); + await this.loadMappings(); + this.toast("Mapping saved"); + }; + + renderMissingTracks() { + const playlists = asArray(this.playlists?.playlists || this.playlists?.Playlists); + const missingCount = playlists.reduce((sum, playlist) => sum + Number(playlist.externalMissing || playlist.ExternalMissing || 0), 0); + return html` +
+
+ + ${missingCount} unmatched +
+
+
+ + + + ${playlists.length ? playlists.map((playlist) => html` + + + + + + + + `) : html``} + +
PlaylistTracksLocalExternalMissing
${playlist.name}${display(playlist.trackCount)}${display(playlist.localTracks)}${display(playlist.externalTracks)}${display(playlist.externalMissing || 0)}
No playlist data loaded.
+
+ `; + } + + renderSongMigration() { + const playlists = asArray(this.playlists?.playlists || this.playlists?.Playlists); + const externalTotal = playlists.reduce((sum, playlist) => sum + Number(playlist.externalTracks || 0), 0); + return html` +
+
Playlists${playlists.length}
+
External tracks${externalTotal}
+
+
+
+ + +
+
+ ${this.renderMissingTracks()} + `; + } + + downloadMigrationCsv() { + const playlists = asArray(this.playlists?.playlists || this.playlists?.Playlists); + const csv = [ + "playlist,total,local,external,missing", + ...playlists.map((playlist) => [ + playlist.name, + playlist.trackCount, + playlist.localTracks, + playlist.externalTracks, + playlist.externalMissing || 0, + ].map((value) => `"${String(value ?? "").replaceAll("\"", "\"\"")}"`).join(",")), + ].join("\n"); + const blob = new Blob([csv], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `allstarr-song-migration-${new Date().toISOString().slice(0, 10)}.csv`; + link.click(); + URL.revokeObjectURL(url); + } + + renderKeptDownloads() { + const files = asArray(this.downloads?.files || this.downloads?.Files); + return html` +
+
Files${display(this.downloads?.count ?? this.downloads?.Count ?? files.length)}
+
Size${display(this.downloads?.totalSizeFormatted ?? this.downloads?.TotalSizeFormatted)}
+
+
+
+ + +
+
+
+ + + + ${files.length ? files.map((file) => html` + + + + + + + + `) : html``} + +
ArtistAlbumFileSize
${display(file.artist)}${display(file.album)}${display(file.fileName)}${display(file.sizeFormatted)}
No kept downloads found.
+
+ `; + } + + renderExternalPlaylistExplorer() { + const results = asArray(this.externalPlaylists?.results || this.externalPlaylists?.Results); + return html` +
+
+
+ + +
+
+ + { this.externalPlaylistQuery = event.target.value; }} @keydown=${(event) => { if (event.key === "Enter") this.searchExternalPlaylists(); }}> +
+ +
+
+
+ ${results.length ? results.map((playlist) => this.renderExternalPlaylistCard(playlist)) : html`
No external playlists loaded.
`} +
+ `; + } + + async searchExternalPlaylists() { + if (!this.externalPlaylistQuery.trim()) { + return; + } + this.externalPlaylists = await API.externalPlaylistSearch(this.externalPlaylistQuery.trim(), this.externalPlaylistProvider); + } + + renderExternalPlaylistCard(playlist) { + const provider = playlist.externalProvider || playlist.Provider; + const externalId = playlist.externalId || playlist.ExternalId; + const key = `${provider}:${externalId}`; + const tracks = this.externalPlaylistTracks.get(key); + return html` +
+
+
+ ${playlist.name || playlist.Name} + ${display(playlist.curatorName || playlist.CuratorName, provider)} +
+ ${provider} +
+
+
Tracks${display(playlist.trackCount || playlist.TrackCount)}
+
ID${display(externalId)}
+
+ + ${tracks ? html` +
+ ${asArray(tracks.results || tracks.Results).slice(0, 5).map((track) => html` +
+ ${track.title || track.Title} + ${track.artist || track.Artist} +
+ `)} +
+ ` : nothing} +
+ `; + } + + renderSources() { + return html` +
+
+
+

Services and sources

+

Provider configuration, source priority, and extension management.

+
+
+
+ ${asArray(this.schema?.providers).map((provider) => this.renderProviderCard(provider))} +
+ ${this.renderPriorityGroups()} + ${this.renderExtensions()} +
+ `; + } + + renderProviderCard(provider) { + return html` +
+
+
+ ${provider.name} + ${asArray(provider.categories).join(", ")} +
+ ${titleCase(provider.status)} +
+
+ ${asArray(provider.notes).map((note) => html`${note}`)} +
+
+ ${asArray(provider.configSchema).map((field) => this.renderConfigField(field))} +
+
+ `; + } + + renderPriorityGroups() { + return html` +
+

Provider priority

+
+ ${asArray(this.schema?.priorityGroups).map((group) => html` +
+

${group.label}

+
+ ${asArray(group.providers).map((provider, index) => html` + + ${provider} + + + + `)} +
+ ${group.enabledEnvKey ? html` +
+ ${this.renderConfigField({ + key: group.enabledEnvKey, + label: "Enabled providers", + type: "text", + valuePath: group.id === "metadata" ? "providers.enabledSearch" : "providers.enabledPlaylist", + requiresRestart: true, + })} +
+ ` : nothing} +
+ `)} +
+
+ `; + } + + async movePriority(group, index, direction) { + const providers = [...group.providers]; + const target = index + direction; + [providers[index], providers[target]] = [providers[target], providers[index]]; + await this.savePriority(group, providers); + } + + renderExtensions() { + const installed = asArray(this.installedExtensions); + const storeItems = asArray(this.extensionStore?.items || this.extensionStore?.Items || this.extensionStore); + const errors = asArray(this.extensionStore?.errors || this.extensionStore?.Errors); + const repoField = { + key: "EXTENSION_REPOSITORIES", + label: "Extension repositories", + type: "text", + valuePath: "extensions.repositories", + requiresRestart: false, + }; + return html` +
+
+
+

Extension store

+

Installed extensions are loaded into the metadata/search sandbox.

+
+
+ + +
+
+
${this.renderConfigField(repoField)}
+
+ ${errors.length ? html`
${errors.map((error) => html`
${error.Repository || error.repository}: ${error.Message || error.message}
`)}
` : nothing} +
+
+

Installed extensions

+
+ ${installed.length ? installed.map((item) => html` +
+ ${item.displayName || item.DisplayName || item.name || item.Name} + ${display(item.description || item.Description)} +
+ ${asArray(item.types || item.Types).map((type) => html`${type}`)} + +
+
+ `) : html`
No extensions installed.
`} +
+
+
+

Store

+
+ ${storeItems.length ? storeItems.map((item) => html` +
+ ${item.displayName || item.DisplayName} + ${display(item.description || item.Description)} +
+ ${display(item.version || item.Version)} + +
+
+ `) : html`
Load the store to browse extensions.
`} +
+
+
+ `; + } + + renderActivity() { + return html` +
+
+
+

Activity

+

Download queue, scrobbling, and endpoint usage.

+
+ +
+
+
+

Download queue

+ ${this.renderActivityList(this.activity)} +
+
+

Scrobbling

+ ${this.renderScrobbling()} +
+
+
+
+

API analytics

+ +
+ ${this.renderEndpointUsage()} +
+
+ `; + } + + renderActivityList(items) { + const entries = asArray(items); + if (!entries.length) { + return html`
No active download or playback activity.
`; + } + return html` +
+ ${entries.map((entry) => html` +
+
+
+ ${display(entry.title || entry.Title, "Unknown track")} +
${display(entry.artist || entry.Artist)} ${entry.externalProvider || entry.ExternalProvider ? `- ${entry.externalProvider || entry.ExternalProvider}` : ""}
+
+ ${display(entry.status || entry.Status)} +
+
+ +
+
+ `)} +
+ `; + } + + renderScrobbling() { + const status = this.scrobbling || {}; + const config = this.config?.scrobbling || {}; + const fields = [ + { key: "SCROBBLING_ENABLED", label: "Scrobbling", type: "toggle", valuePath: "scrobbling.enabled" }, + { key: "SCROBBLING_LOCAL_TRACKS_ENABLED", label: "Local tracks", type: "toggle", valuePath: "scrobbling.localTracksEnabled" }, + { key: "SCROBBLING_LASTFM_ENABLED", label: "Last.fm", type: "toggle", valuePath: "scrobbling.lastFm.enabled" }, + { key: "SCROBBLING_LASTFM_API_KEY", label: "Last.fm API key", type: "password", valuePath: "scrobbling.lastFm.apiKey", sensitive: true }, + { key: "SCROBBLING_LASTFM_SHARED_SECRET", label: "Last.fm secret", type: "password", valuePath: "scrobbling.lastFm.sharedSecret", sensitive: true }, + { key: "SCROBBLING_LISTENBRAINZ_ENABLED", label: "ListenBrainz", type: "toggle", valuePath: "scrobbling.listenBrainz.enabled" }, + { key: "SCROBBLING_LISTENBRAINZ_USER_TOKEN", label: "ListenBrainz token", type: "password", valuePath: "scrobbling.listenBrainz.userToken", sensitive: true }, + ]; + return html` +
+
Runtime${status.enabled || status.Enabled ? "Enabled" : "Disabled"}
+
+
${fields.map((field) => this.renderConfigField(field))}
+
+ + +
+ `; + } + + renderEndpointUsage() { + const endpoints = asArray(this.endpointUsage?.endpoints || this.endpointUsage?.Endpoints); + return html` +
+
Requests${display(this.endpointUsage?.totalRequests || this.endpointUsage?.TotalRequests || 0)}
+
Endpoints${display(this.endpointUsage?.totalEndpoints || this.endpointUsage?.TotalEndpoints || endpoints.length)}
+
+
+ + + + ${endpoints.length ? endpoints.map((item) => html``) : html``} + +
EndpointCount
${item.endpoint || item.Endpoint}${item.count || item.Count}
No endpoint usage data.
+
+ `; + } + + renderSettings() { + return html` +
+
+
+

Settings

+

Configuration changes are saved on blur and marked when a restart is needed.

+
+
+ ${asArray(this.schema?.configSections).map((section) => html` +
+

${section.label}

+
+ ${asArray(section.fields).map((field) => this.renderConfigField(field))} +
+
+ `)} +
+

Backup and restore

+
+ + this.importEnv(event)}> + +
+
+
+

Danger zone

+
+ + +
+
+
+ `; + } + + renderConfigField(field) { + const rawValue = getPathValue(this.config, field.valuePath, ""); + const value = field.sensitive ? "" : rawValue; + const saved = this.restartKeys.has(field.key); + const onCommit = async (event) => { + const target = event.currentTarget; + const nextValue = field.type === "toggle" ? (target.checked ? "true" : "false") : target.value; + try { + await this.saveField(field, nextValue); + } catch (error) { + this.toast(error.message, "error"); + } + }; + + return html` +
+
+ + ${saved ? html`Restart needed` : nothing} +
+ ${field.type === "select" ? html` + + ` : field.type === "toggle" ? html` + + ` : html` + + `} +
+ `; + } + + async exportEnv() { + const blob = await API.exportEnv(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "allstarr.env"; + link.click(); + URL.revokeObjectURL(url); + } + + async importEnv(event) { + const file = event.currentTarget.files?.[0]; + if (!file) { + return; + } + await API.importEnv(file); + await this.loadConfig(); + this.restartKeys = new Set([...this.restartKeys, "IMPORT_ENV"]); + this.toast(".env imported"); + } + + renderRestartBar() { + if (!this.restartKeys.size) { + return nothing; + } + return html` +
+ ${this.restartKeys.size} saved change${this.restartKeys.size === 1 ? "" : "s"} need restart + + +
+ `; + } + + renderNowPlaying() { + const current = this.activity.find((item) => item.isPlaying || item.IsPlaying) || this.activity[0]; + const progress = current ? percent(current.playbackProgress ?? current.PlaybackProgress ?? current.progress ?? current.Progress) : 0; + return html` +
+
+
${current ? display(current.title || current.Title, "Active download") : "No active playback"}
+
${current ? display(current.artist || current.Artist) : "Queue is idle"}
+
+
+
+ `; + } + + renderToasts() { + return html` +
+ ${this.toasts.map((toast) => html`
${toast.message}
`)} +
+ `; + } +} + +customElements.define("allstarr-app", AllstarrApp); diff --git a/allstarr/wwwroot/spotify-mappings.html b/allstarr/wwwroot/spotify-mappings.html index 88181b2f..e26fe279 100644 --- a/allstarr/wwwroot/spotify-mappings.html +++ b/allstarr/wwwroot/spotify-mappings.html @@ -1,768 +1,17 @@ - - - - Spotify Track Mappings - Allstarr - - - - - -
-
-

Spotify Track Mappings

- ← Back to Dashboard -
- -
-
-
Total Mappings
-
-
-
-
-
Local Tracks
-
-
-
-
-
External Tracks
-
-
-
-
-
Manual Mappings
-
-
-
-
-
Auto Mappings
-
-
-
-
- -
-
-

All Mappings

-
-
- - -
- -
-
- -
-
Loading mappings...
-
- - -
-
- - - - - - -