refactor(WebUI): ENTIRE rewrite of most of the UI, redoing of the extension store, hopefully properly setting up the app for the future

This commit is contained in:
joshpatra committed 2026-07-08 21:48:29 -04:00
1 parent 228af068c3
commit 25f7b063cb
16 files changed
+3562 -2651

No files matched your search

+69
View File
@@ -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);
}
}
+28 -160
View File
@@ -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("<allstarr-app>", 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;
}
}
}
@@ -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()
{
+361
View File
@@ -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> spotifyApiSettings,
IOptions<DeezerSettings> deezerSettings,
IOptions<QobuzSettings> qobuzSettings,
IOptions<SquidWTFSettings> squidWtfSettings,
IOptions<AppleMusicSettings> appleMusicSettings,
IOptions<MusicBrainzSettings> 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<string>("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<AdminUiRoute> 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<AdminUiBackend> 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<AdminUiProvider> 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<AdminUiPriorityGroup> 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<AdminUiConfigSection> 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<AdminUiConfigField> fields) =>
new() { Id = id, Label = label, Fields = fields };
private static AdminUiConfigField Field(
string key,
string label,
string type,
string? valuePath,
List<string>? 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
};
}
+12
View File
@@ -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<int>("Cache:ProxyImagesDays", 14)),
transcodeCacheMinutes = GetEnvInt(envVars, "CACHE_TRANSCODE_MINUTES", _configuration.GetValue<int>("Cache:TranscodeCacheMinutes", 60))
},
extensions = new
{
repositories = GetEnvString(
envVars,
"EXTENSION_REPOSITORIES",
_configuration.GetValue<string>("EXTENSION_REPOSITORIES") ??
"https://raw.githubusercontent.com/spotiflacapp/SpotiFLAC-Extension/main/registry.json")
},
scrobbling = await GetScrobblingSettingsFromEnvAsync()
});
}
+15 -5
View File
@@ -27,8 +27,8 @@ public class ExtensionController : ControllerBase
[HttpGet("store")]
public async Task<IActionResult> 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<IActionResult> 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; } = "";
}
+133
View File
@@ -1315,6 +1315,139 @@ public class PlaylistController : ControllerBase
}
}
/// <summary>
/// Search a specific external provider for playlists for the admin UI.
/// </summary>
[HttpGet("external/playlists/search")]
public async Task<IActionResult> 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" });
}
}
/// <summary>
/// Preview tracks from a specific external provider playlist.
/// </summary>
[HttpGet("external/playlists/{provider}/{externalId}/tracks")]
public async Task<IActionResult> 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<IConcreteMetadataService>();
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)));
}
/// <summary>
/// Get track details by Jellyfin ID (for URL-based mapping)
/// </summary>
+180
View File
@@ -0,0 +1,180 @@
using System.Text.Json.Serialization;
namespace allstarr.Models.Admin;
public sealed class AdminUiSchemaResponse
{
[JsonPropertyName("routes")]
public List<AdminUiRoute> Routes { get; set; } = [];
[JsonPropertyName("backends")]
public List<AdminUiBackend> Backends { get; set; } = [];
[JsonPropertyName("activeBackend")]
public string ActiveBackend { get; set; } = "Jellyfin";
[JsonPropertyName("providers")]
public List<AdminUiProvider> Providers { get; set; } = [];
[JsonPropertyName("multiProviderCategories")]
public List<string> MultiProviderCategories { get; set; } = [];
[JsonPropertyName("priorityGroups")]
public List<AdminUiPriorityGroup> PriorityGroups { get; set; } = [];
[JsonPropertyName("configSections")]
public List<AdminUiConfigSection> ConfigSections { get; set; } = [];
[JsonPropertyName("extensionStore")]
public AdminUiExtensionStore ExtensionStore { get; set; } = new();
[JsonPropertyName("pluginCapabilities")]
public List<AdminUiPluginCapability> 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<AdminUiConfigField> 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<string> Categories { get; set; } = [];
[JsonPropertyName("configSchema")]
public List<AdminUiConfigField> ConfigSchema { get; set; } = [];
[JsonPropertyName("notes")]
public List<string> 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<string> 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<AdminUiConfigField> 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<string> 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<string> 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; }
}
+2
View File
@@ -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;
+314 -42
View File
@@ -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<ExtensionManager> _logger;
private readonly IConfiguration _configuration;
private readonly AdminHelperService _adminHelperService;
private readonly string _extensionsDir;
private readonly ConcurrentDictionary<string, ExtensionSandbox> _activeExtensions = new();
@@ -25,11 +28,13 @@ public class ExtensionManager
public ExtensionManager(
IHttpClientFactory httpClientFactory,
ILogger<ExtensionManager> 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<string> 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<List<StoreExtensionItem>> FetchStoreExtensionsAsync(CancellationToken cancellationToken = default)
{
var items = new List<StoreExtensionItem>();
var catalog = await FetchStoreCatalogAsync(cancellationToken);
return catalog.Items;
}
public async Task<ExtensionStoreResponse> 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<bool> 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<StoreExtensionItem> ParseStoreRegistry(string json, string repoUrl = "")
{
var items = new List<StoreExtensionItem>();
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<string> 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<string> Repositories { get; set; } = [];
public List<StoreExtensionItem> Items { get; set; } = [];
public List<ExtensionStoreError> 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<string> 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
@@ -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"],
+718
View File
@@ -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);
}
}
+80
View File
@@ -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);
}
}
+10 -1682
View File
@@ -1,1690 +1,18 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Allstarr Dashboard</title>
<link rel="stylesheet" href="styles.css">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>Allstarr</title>
<link rel="stylesheet" href="/css/tokens.css">
<link rel="stylesheet" href="/css/base.css">
</head>
<body>
<!-- Restart Required Banner -->
<div class="restart-banner" id="restart-banner">
⚠️ Configuration changed. Restart required to apply changes.
<button data-action="restartContainer">Restart Allstarr</button>
<button data-action="dismissRestartBanner"
style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button>
</div>
<div class="auth-gate" id="auth-gate">
<div class="auth-card">
<h2>Sign In With Jellyfin</h2>
<p>Use your Jellyfin account to access the local Allstarr admin UI.</p>
<form id="auth-login-form" autocomplete="off">
<label for="auth-username">Username</label>
<input id="auth-username" type="text" required>
<label for="auth-password">Password</label>
<input id="auth-password" type="password" required>
<label class="auth-checkbox" for="auth-remember-me">
<input id="auth-remember-me" type="checkbox">
<span>Keep me signed in for 30 days on this browser</span>
</label>
<small class="auth-note">Use only on a device you trust.</small>
<button class="primary" type="submit">Sign In</button>
<div class="auth-error" id="auth-error" role="alert"></div>
</form>
</div>
<div class="support-badge">
<p class="support-text">
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
supporting its development
</p>
<ul class="support-funding-icons">
<li>
<a class="support-funding-link" href="https://ko-fi.com/joshpatra" target="_blank"
rel="noopener noreferrer" aria-label="Support on Ko-fi">
<img src="images/kofi_symbol.svg" alt="" width="37" height="30" decoding="async" />
</a>
</li>
<li>
<a class="support-funding-link" href="https://github.com/sponsors/SoPat712" target="_blank"
rel="noopener noreferrer" aria-label="GitHub Sponsors">
<img src="images/github-mark.svg" alt="" width="30" height="29" decoding="async" />
</a>
</li>
<li>
<a class="support-funding-link" href="https://buymeacoffee.com/treeman183" target="_blank"
rel="noopener noreferrer" aria-label="Buy Me a Coffee">
<img src="images/buymeacoffee-symbol.svg" alt="" width="30" height="30" decoding="async" />
</a>
</li>
</ul>
</div>
</div>
<div class="container hidden" id="main-container">
<div class="app-shell">
<aside class="sidebar" aria-label="Admin navigation">
<div class="sidebar-brand">
<div class="sidebar-title">
<a class="title-link" href="https://github.com/SoPat712/allstarr" target="_blank"
rel="noopener noreferrer">Allstarr</a>
</div>
<div class="sidebar-subtitle" id="sidebar-version">Loading...</div>
<div class="sidebar-status" id="status-indicator">
<span class="status-badge" id="spotify-status">
<span class="status-dot"></span>
<span>Loading...</span>
</span>
</div>
</div>
<nav class="sidebar-nav">
<button class="sidebar-link active" type="button" data-tab="dashboard">Dashboard</button>
<button class="sidebar-link" type="button" data-tab="jellyfin-playlists">Link Playlists</button>
<button class="sidebar-link" type="button" data-tab="playlists">Injected Playlists</button>
<button class="sidebar-link" type="button" data-tab="song-migration">Song Migration</button>
<button class="sidebar-link" type="button" data-tab="kept">Kept Downloads</button>
<button class="sidebar-link" type="button" data-tab="services">Services</button>
<button class="sidebar-link" type="button" data-tab="config">Configuration</button>
<button class="sidebar-link" type="button" data-tab="extensions">Extensions</button>
<button class="sidebar-link" type="button" data-tab="report-issues">Report Issues</button>
<button class="sidebar-link" type="button" data-tab="endpoints">API Analytics</button>
</nav>
<div class="sidebar-footer">
<div class="auth-user hidden" id="auth-user-display">
Signed in as <strong id="auth-user-name">-</strong>
</div>
<button id="auth-logout-btn" data-action="logoutAdminSession" class="hidden">Logout</button>
</div>
</aside>
<main class="app-main">
<div class="tabs top-tabs" aria-hidden="true">
<div class="tab active" data-tab="dashboard">Dashboard</div>
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
<div class="tab" data-tab="playlists">Injected Playlists</div>
<div class="tab" data-tab="song-migration">Song Migration</div>
<div class="tab" data-tab="kept">Kept Downloads</div>
<div class="tab" data-tab="services">Services</div>
<div class="tab" data-tab="config">Configuration</div>
<div class="tab" data-tab="extensions">Extensions</div>
<div class="tab" data-tab="report-issues">Report Issues</div>
<div class="tab" data-tab="endpoints">API Analytics</div>
</div>
<!-- Dashboard Tab -->
<div class="tab-content active" id="tab-dashboard">
<!-- Interactive Setup Wizard -->
<div class="setup-wizard-card" id="allstarr-setup-wizard">
<h2>Allstarr Setup Guide</h2>
<p class="wizard-intro">Welcome! Let's configure your local server backend and connect your streaming accounts step-by-step.</p>
<div class="setup-stepper">
<div class="setup-stepper-progress" id="wizard-progress-bar"></div>
<div class="setup-step active" onclick="jumpToWizardStep(1)">
<div class="setup-step-circle">1</div>
<div class="setup-step-label">Backend Link</div>
</div>
<div class="setup-step" onclick="jumpToWizardStep(2)">
<div class="setup-step-circle">2</div>
<div class="setup-step-label">Music Provider</div>
</div>
<div class="setup-step" onclick="jumpToWizardStep(3)">
<div class="setup-step-circle">3</div>
<div class="setup-step-label">Spotify API</div>
</div>
<div class="setup-step" onclick="jumpToWizardStep(4)">
<div class="setup-step-circle">4</div>
<div class="setup-step-label">Confirm</div>
</div>
</div>
<div class="setup-wizard-content" id="wizard-step-container">
<!-- Injected by JavaScript -->
</div>
<div class="setup-wizard-actions">
<button id="wizard-back-btn" onclick="prevWizardStep()" style="visibility: hidden;">&larr; Back</button>
<div class="right-buttons">
<button id="wizard-skip-btn" onclick="skipWizardStep()" class="secondary">Skip Step</button>
<button id="wizard-next-btn" onclick="nextWizardStep()" class="primary">Next Step &rarr;</button>
</div>
</div>
</div>
<div class="grid">
<div class="card">
<h2>Spotify API</h2>
<div class="stat-row">
<span class="stat-label">Status</span>
<span class="stat-value" id="spotify-auth-status">Loading...</span>
</div>
<div class="stat-row">
<span class="stat-label">User</span>
<span class="stat-value" id="spotify-user">-</span>
</div>
<div class="stat-row">
<span class="stat-label">Cookie Age</span>
<span class="stat-value" id="spotify-cookie-age">-</span>
</div>
<div class="stat-row">
<span class="stat-label">Cache Duration</span>
<span class="stat-value" id="cache-duration">-</span>
</div>
<div class="stat-row">
<span class="stat-label">ISRC Matching</span>
<span class="stat-value" id="isrc-matching">-</span>
</div>
</div>
<div class="card">
<h2>Jellyfin</h2>
<div class="stat-row">
<span class="stat-label">Backend</span>
<span class="stat-value" id="backend-type">-</span>
</div>
<div class="stat-row">
<span class="stat-label">URL</span>
<span class="stat-value" id="jellyfin-url">-</span>
</div>
<div class="stat-row">
<span class="stat-label">Playlists</span>
<span class="stat-value" id="playlist-count">-</span>
</div>
</div>
</div>
<div class="card">
<h2>
Quick Actions
</h2>
<div id="dashboard-guidance" class="guidance-stack"></div>
<div class="card-actions-row">
<button class="primary" data-action="refreshPlaylists">Refresh All Playlists</button>
<button data-action="clearCache">Clear Cache</button>
<button data-action="openAddPlaylist">Add Playlist</button>
<button onclick="window.location.href='/spotify-mappings.html'">View Spotify Mappings</button>
<button onclick="document.getElementById('allstarr-setup-wizard').style.display='block'; window.scrollTo({top: 0, behavior: 'smooth'});">Setup Guide</button>
</div>
</div>
</div>
<!-- Link Playlists Tab -->
<div class="tab-content" id="tab-jellyfin-playlists">
<div class="card">
<h2>
Link Jellyfin Playlists to Spotify
<div class="actions">
<button onclick="fetchJellyfinPlaylists()">Refresh</button>
</div>
</h2>
<p class="text-secondary mb-16">
Connect your Jellyfin playlists to Spotify playlists. Allstarr will automatically fill in missing
tracks from Spotify using your preferred music service (SquidWTF/Deezer/Qobuz).
<br><strong>Tip:</strong> Use the sp_dc cookie method for best results - it's simpler and more
reliable.
</p>
<div id="jellyfin-guidance" class="guidance-stack"></div>
<div id="jellyfin-user-filter" class="flex-row-wrap mb-16">
<div class="form-group jellyfin-user-form-group">
<label class="text-secondary">User</label>
<select id="jellyfin-user-select" onchange="fetchJellyfinPlaylists()"
style="width: 100%; padding: 8px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);">
<option value="">All Users</option>
</select>
</div>
</div>
<table class="playlist-table">
<thead>
<tr>
<th>Name</th>
<th>Tracks</th>
<th>Status</th>
<th>...</th>
</tr>
</thead>
<tbody id="jellyfin-playlist-table-body">
<tr>
<td colspan="4" class="loading">
<span class="spinner"></span> Loading Jellyfin playlists...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Active Playlists Tab -->
<div class="tab-content" id="tab-playlists">
<!-- Warning Banner (hidden by default) -->
<div id="matching-warning-banner" class="guidance-banner warning matching-progress-banner" style="display:none;">
⚠️ TRACK MATCHING IN PROGRESS - Please wait for matching to complete before making changes to playlists
or mappings!
</div>
<div class="card">
<h2>Playlist Injection Settings</h2>
<div class="guidance-banner info compact">
ℹ️ Music Library ID is required for playlist injection. Get it from your Jellyfin music library URL.
</div>
<div class="config-section">
<div class="config-item">
<span class="label">Music Library ID <span style="color: var(--error);">*</span></span>
<span class="value" id="config-jellyfin-library-id">-</span>
<button
onclick="openEditSetting('JELLYFIN_LIBRARY_ID', 'Music Library ID', 'text', 'Required for playlist injection. Get from Jellyfin music library URL.')">Edit</button>
</div>
</div>
</div>
<div class="card">
<h2>
Injected Spotify Playlists
<div class="actions">
<button onclick="matchAllPlaylists()"
title="Re-match tracks when local library changed (uses cached Spotify data)">Rematch All</button>
<button onclick="refreshPlaylists()"
title="Fetch the latest playlist data from Spotify without re-matching tracks">Refresh All</button>
<button class="primary" onclick="refreshAndMatchAll()"
title="Rebuild all playlists when Spotify playlists changed (clears cache, fetches fresh data, re-matches)"
>Rebuild All</button>
</div>
</h2>
<details class="advanced-section">
<summary>Advanced: Rematch vs Refresh vs Rebuild</summary>
<div class="advanced-section-content">
<div class="advanced-guide-list">
<div><strong>Rematch</strong>: Use when your <em>local Jellyfin library</em> changed. Fast and uses cached Spotify data.</div>
<div><strong>Refresh</strong>: Pull fresh Spotify playlist items without re-matching.</div>
<div><strong>Rebuild</strong>: Full reset when <em>Spotify playlist content</em> changed. Clears cache and re-matches everything.</div>
</div>
</div>
</details>
<p class="text-secondary mb-12">
These are the Spotify playlists currently being injected into Jellyfin with tracks from your music
service.
</p>
<div id="playlists-guidance" class="guidance-stack"></div>
<table class="playlist-table">
<thead>
<tr>
<th>Name</th>
<th>Tracks</th>
<th>Status</th>
<th>...</th>
</tr>
</thead>
<tbody id="playlist-table-body">
<tr>
<td colspan="4" class="loading">
<span class="spinner"></span> Loading playlists...
</td>
</tr>
</tbody>
</table>
</div>
<!-- Manual Track Mappings Section -->
<div class="card">
<h2>
Manual Track Mappings
<div class="actions">
<button onclick="fetchTrackMappings()">Refresh</button>
</div>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 12px;">
Manual mappings override automatic matching for external providers (SquidWTF, Deezer, Qobuz). For
local Jellyfin tracks, use the Spotify Import plugin instead.
</p>
<div id="mappings-summary" class="summary-box">
<div>
<span class="summary-label">Total:</span>
<span class="summary-value" id="mappings-total">0</span>
</div>
<div>
<span class="summary-label">External:</span>
<span class="summary-value success"
id="mappings-external">0</span>
</div>
</div>
<table class="playlist-table">
<thead>
<tr>
<th>Playlist</th>
<th>Spotify ID</th>
<th>Type</th>
<th>Target IDs</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="mappings-table-body">
<tr>
<td colspan="6" class="loading">
<span class="spinner"></span> Loading mappings...
</td>
</tr>
</tbody>
</table>
</div>
<!-- Missing Tracks Section -->
<div class="card">
<h2>
Missing Tracks (All Playlists)
<div class="actions">
<button onclick="fetchMissingTracks()">Refresh</button>
</div>
</h2>
<p class="text-secondary mb-12">
Tracks that couldn't be matched locally or externally. Map them manually to add them to your
playlists.
</p>
<div id="missing-summary" class="summary-box">
<div>
<span class="summary-label">Total Missing:</span>
<span class="summary-value warning"
id="missing-total">0</span>
</div>
</div>
<table class="playlist-table">
<thead>
<tr>
<th>Playlist</th>
<th>Track</th>
<th>Artist</th>
<th>Album</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="missing-tracks-table-body">
<tr>
<td colspan="5" class="loading">
<span class="spinner"></span> Loading missing tracks...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Song Migration Tab -->
<div class="tab-content" id="tab-song-migration">
<div class="card">
<h2>
Song Migration
<div class="actions">
<button class="primary" data-action="downloadSongMigrationCsv"
title="Download all non-Jellyfin tracks (external + missing) across all injected playlists as CSV">Download CSV (All)</button>
<button data-action="fetchSongMigration">Refresh</button>
</div>
</h2>
<p class="text-secondary mb-12">
Tracks from your injected Spotify playlists that are <strong>not</strong> in your Jellyfin library.
This includes tracks matched through external providers (SquidWTF, Deezer, Qobuz) and tracks that
are still missing. Use the CSV export to download these tracks with your preferred tool.
</p>
<div id="song-migration-guidance" class="guidance-stack"></div>
<div id="song-migration-summary" class="summary-box">
<div>
<span class="summary-label">Playlists:</span>
<span class="summary-value accent" id="song-migration-playlist-count">0</span>
</div>
<div>
<span class="summary-label">External:</span>
<span class="summary-value success" id="song-migration-external-count">0</span>
</div>
<div>
<span class="summary-label">Missing:</span>
<span class="summary-value warning" id="song-migration-missing-count">0</span>
</div>
<div>
<span class="summary-label">Total to Migrate:</span>
<span class="summary-value accent" id="song-migration-total-count">0</span>
</div>
</div>
<div class="table-scroll song-migration-table-scroll">
<table class="playlist-table">
<thead>
<tr>
<th>Name</th>
<th>Non-Local Tracks</th>
<th>Status</th>
<th>...</th>
</tr>
</thead>
<tbody id="song-migration-table-body">
<tr>
<td colspan="4" class="loading">
<span class="spinner"></span> Loading playlists...
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Kept Downloads Tab -->
<div class="tab-content" id="tab-kept">
<div class="card">
<h2>
Kept Downloads
<div class="actions">
<button onclick="downloadAllKept()" class="primary">Download All</button>
<button onclick="deleteAllKept()" class="danger">Delete All</button>
<button onclick="fetchDownloads()">Refresh</button>
</div>
</h2>
<p class="text-secondary mb-12">
Downloaded files stored permanently. Download individual tracks or download all as a zip archive.
</p>
<div id="downloads-summary" class="summary-box">
<div>
<span class="summary-label">Total Files:</span>
<span class="summary-value accent"
id="downloads-count">0</span>
</div>
<div>
<span class="summary-label">Total Size:</span>
<span class="summary-value accent" id="downloads-size">0
B</span>
</div>
</div>
<table class="playlist-table">
<thead>
<tr>
<th>Artist</th>
<th>Album</th>
<th>File</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="downloads-table-body">
<tr>
<td colspan="5" class="loading">
<span class="spinner"></span> Loading downloads...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Services Tab -->
<div class="tab-content" id="tab-services">
<h1 class="mb-16">Streaming Services</h1>
<p class="text-secondary mb-24">Configure external music streaming platforms for playback integration and on-the-fly decryption.</p>
<div class="card">
<h2>Spotify API Settings</h2>
<div style="background: rgba(248, 81, 73, 0.15); border: 1px solid var(--error); border-radius: 6px; padding: 12px; margin-bottom: 16px; color: var(--text-primary);">
⚠️ For active playlists and link functionality to work, sp_dc session cookie must be set!
</div>
<div class="config-section">
<div class="config-item">
<span class="label">API Enabled</span>
<input type="checkbox" id="config-spotify-enabled" data-env-key="SPOTIFY_API_ENABLED" class="config-input">
</div>
<div class="config-item">
<span class="label">Session Cookie (sp_dc) <span style="color: var(--error);">*</span></span>
<input type="password" id="config-spotify-cookie" data-env-key="SPOTIFY_API_SESSION_COOKIE" class="config-input" placeholder="Enter sp_dc cookie">
</div>
<div class="config-item">
<span class="label">Cache Duration (mins)</span>
<input type="number" id="config-cache-duration" data-env-key="SPOTIFY_API_CACHE_DURATION_MINUTES" class="config-input" min="1">
</div>
<div class="config-item">
<span class="label">ISRC Matching</span>
<input type="checkbox" id="config-isrc-matching" data-env-key="SPOTIFY_API_PREFER_ISRC_MATCHING" class="config-input">
</div>
</div>
</div>
<div class="card">
<h2>SquidWTF / Tidal Settings</h2>
<div class="config-section">
<div class="config-item">
<span class="label">Quality</span>
<select id="config-squid-quality" data-env-key="SQUIDWTF_QUALITY" class="config-input">
<option value="HI_RES_LOSSLESS">HI_RES_LOSSLESS (24-bit/192kHz FLAC)</option>
<option value="LOSSLESS">LOSSLESS (16-bit/44.1kHz FLAC)</option>
<option value="HIGH">HIGH (320kbps AAC)</option>
<option value="LOW">LOW (96kbps AAC)</option>
</select>
</div>
<div class="config-item">
<span class="label">Request Interval (ms)</span>
<input type="number" id="config-squid-ratelimit" data-env-key="SQUIDWTF_MIN_REQUEST_INTERVAL_MS" class="config-input" min="0">
</div>
</div>
</div>
<div class="card">
<h2>Deezer Settings</h2>
<div class="config-section">
<div class="config-item">
<span class="label">ARL Token</span>
<input type="password" id="config-deezer-arl" data-env-key="DEEZER_ARL" class="config-input" placeholder="Enter Deezer ARL token">
</div>
<div class="config-item">
<span class="label">Quality</span>
<select id="config-deezer-quality" data-env-key="DEEZER_QUALITY" class="config-input">
<option value="FLAC">FLAC</option>
<option value="MP3_320">MP3_320</option>
<option value="MP3_128">MP3_128</option>
</select>
</div>
<div class="config-item">
<span class="label">Request Interval (ms)</span>
<input type="number" id="config-deezer-ratelimit" data-env-key="DEEZER_MIN_REQUEST_INTERVAL_MS" class="config-input" min="0">
</div>
</div>
</div>
<div class="card">
<h2>Qobuz Settings</h2>
<div class="config-section">
<div class="config-item">
<span class="label">User Auth Token</span>
<input type="password" id="config-qobuz-token" data-env-key="QOBUZ_USER_AUTH_TOKEN" class="config-input" placeholder="Enter Qobuz user auth token">
</div>
<div class="config-item">
<span class="label">Preferred Quality</span>
<select id="config-qobuz-quality" data-env-key="QOBUZ_QUALITY" class="config-input">
<option value="FLAC">FLAC</option>
<option value="FLAC_24_HIGH">FLAC_24_HIGH</option>
<option value="FLAC_24_LOW">FLAC_24_LOW</option>
<option value="FLAC_16">FLAC_16</option>
<option value="MP3_320">MP3_320</option>
</select>
</div>
<div class="config-item">
<span class="label">Request Interval (ms)</span>
<input type="number" id="config-qobuz-ratelimit" data-env-key="QOBUZ_MIN_REQUEST_INTERVAL_MS" class="config-input" min="0">
</div>
</div>
</div>
<div class="card">
<h2>Apple Music Settings</h2>
<div class="config-section">
<div class="config-item">
<span class="label">gamdl-aio Sidecar URL</span>
<input type="text" id="config-applemusic-baseurl" data-env-key="APPLE_MUSIC_AIO_URL" class="config-input">
</div>
<div class="config-item">
<span class="label">Preferred Quality</span>
<select id="config-applemusic-quality" data-env-key="APPLE_MUSIC_QUALITY" class="config-input">
<option value="alac-16-44">alac-16-44 (16-bit Lossless)</option>
<option value="alac-24-96">alac-24-96 (24-bit Hi-Res Lossless)</option>
<option value="alac-24-192">alac-24-192 (24-bit Hi-Res Lossless Max)</option>
<option value="aac-320">aac-320 (High Quality AAC)</option>
<option value="aac-96">aac-96 (Low Quality AAC)</option>
</select>
</div>
</div>
</div>
<div class="card" id="applemusic-manager-card">
<h2>Apple Music (gamdl-aio) Manager</h2>
<p class="text-secondary mb-16">
Verify container status, upload a compatible package to stage the native decryption library, and authorize your Apple Music subscription.
</p>
<div class="config-section">
<div class="config-item" style="grid-template-columns: 200px 1fr;">
<span class="label">Native Libs Staged</span>
<span class="value" id="am-staged-status"><span class="status-indicator red"></span> Offline</span>
</div>
<div class="config-item" style="grid-template-columns: 200px 1fr;">
<span class="label">Decryption Daemon</span>
<span class="value" id="am-daemon-status"><span class="status-indicator red"></span> Offline</span>
</div>
<div class="config-item" style="grid-template-columns: 200px 1fr;">
<span class="label">Subscription Authorized</span>
<span class="value" id="am-auth-status"><span class="status-indicator red"></span> Offline</span>
</div>
</div>
<!-- Section A: Upload APK -->
<div id="am-upload-section" class="mt-16" style="border-top: 1px solid var(--border); padding-top: 16px; display: none;">
<h3>1. Upload Apple Music APK/APKM</h3>
<p class="text-secondary mb-8">
To enable FairPlay decryption, the system requires staging native library binaries from a compatible media client application package (specifically targeting version 3.6.0-beta, build 1109, typically distributed as an APK or APKM). By choosing to upload this package, you verify that you possess a licensed copy of the application and are staging it exclusively for private, personal backup emulation.
</p>
<input type="file" id="am-apk-input" accept=".apk,.apkm" style="display: none;">
<button class="primary" onclick="document.getElementById('am-apk-input').click()">Browse & Upload APK/APKM</button>
<div id="am-upload-progress-container" style="display: none; margin-top: 8px;">
<div style="background: var(--bg-secondary); border-radius: 4px; height: 16px; overflow: hidden; position: relative;">
<div id="am-upload-progress-bar" style="background: var(--accent); width: 0%; height: 100%; transition: width 0.3s ease;"></div>
</div>
<span id="am-upload-progress-text" style="font-size: 0.85rem;" class="text-secondary">0%</span>
</div>
</div>
<!-- Section B: Login -->
<div id="am-login-section" class="mt-16" style="border-top: 1px solid var(--border); padding-top: 16px; display: none;">
<h3>2. Apple Music Login</h3>
<p class="text-secondary mb-8">Enter your real Apple ID email and password to login via the decryption daemon.</p>
<div class="form-group mb-8">
<label class="text-secondary">Apple ID (Email)</label>
<input type="email" id="am-username-input" style="width: 100%; padding: 8px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);">
</div>
<div class="form-group mb-12">
<label class="text-secondary">Password</label>
<input type="password" id="am-password-input" style="width: 100%; padding: 8px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);">
</div>
<button class="primary" onclick="submitAppleMusicLogin()">Log In</button>
</div>
<!-- Section C: 2FA -->
<div id="am-tfa-section" class="mt-16" style="border-top: 1px solid var(--border); padding-top: 16px; display: none;">
<h3>3. Two-Factor Authentication</h3>
<p class="text-secondary mb-8">Enter the 6-digit verification code sent to your Apple devices.</p>
<div class="form-group mb-12">
<label class="text-secondary">2FA Verification Code</label>
<input type="text" id="am-tfa-input" placeholder="123456" pattern="\d{6}" maxlength="6" style="width: 100%; padding: 8px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);">
</div>
<button class="primary" onclick="submitAppleMusic2fa()">Verify Code</button>
</div>
</div>
<h1 class="mt-32 mb-16">Scrobblers & Metadata</h1>
<p class="text-secondary mb-24">Configure scrobbling connections and metadata enrichment databases.</p>
<div class="card">
<h2>Scrobbling Configuration</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Scrobble your listening history to Last.fm and ListenBrainz. Tracks are scrobbled when you listen to at least half the track or 4 minutes (whichever comes first).
</p>
<div class="config-section" style="margin-bottom: 24px;">
<div class="config-item">
<span class="label">Scrobbling Enabled</span>
<input type="checkbox" id="scrobbling-enabled-value" data-env-key="SCROBBLING_ENABLED" class="config-input">
</div>
<div class="config-item">
<span class="label">Local Track Scrobbling</span>
<input type="checkbox" id="local-tracks-enabled-value" data-env-key="SCROBBLING_LOCAL_TRACKS_ENABLED" class="config-input">
</div>
<div class="config-item">
<span class="label">Synthetic Local Played Signal</span>
<input type="checkbox" id="synthetic-local-played-signal-enabled-value" data-env-key="SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED" class="config-input">
</div>
</div>
<div style="background: rgba(255, 193, 7, 0.15); border: 1px solid #ffc107; border-radius: 6px; padding: 12px; margin-bottom: 16px; color: var(--text-primary);">
ℹ️ <strong>Recommended:</strong> Keep local track scrobbling disabled and use native Jellyfin plugins instead:
<br>• <a href="https://github.com/danielfariati/jellyfin-plugin-lastfm" target="_blank" style="color: var(--accent);">Last.fm Plugin</a>
<br>• <a href="https://github.com/lyarenei/jellyfin-plugin-listenbrainz" target="_blank" style="color: var(--accent);">ListenBrainz Plugin</a>
<br>This ensures Allstarr only scrobbles external tracks (Spotify, Deezer, Qobuz).
<br><strong>Default:</strong> keep Synthetic Local Played Signal disabled to avoid duplicate plugin scrobbles.
</div>
</div>
<div class="card">
<h2>Last.fm</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Scrobble to Last.fm. Set <code>SCROBBLING_LASTFM_API_KEY</code> and <code>SCROBBLING_LASTFM_SHARED_SECRET</code> in your .env (from <a href="https://www.last.fm/api/account/create" target="_blank" rel="noopener" style="color: var(--accent);">last.fm/api/account/create</a>), restart, then enter username/password and click "Authenticate & Save".
</p>
<div class="config-section" style="margin-bottom: 24px;">
<div class="config-item">
<span class="label">Last.fm Enabled</span>
<input type="checkbox" id="lastfm-enabled-value" data-env-key="SCROBBLING_LASTFM_ENABLED" class="config-input">
</div>
<div class="config-item">
<span class="label">Username</span>
<input type="text" id="lastfm-username-value" data-env-key="SCROBBLING_LASTFM_USERNAME" class="config-input">
</div>
<div class="config-item">
<span class="label">Password</span>
<input type="password" id="lastfm-password-value" data-env-key="SCROBBLING_LASTFM_PASSWORD" class="config-input" placeholder="Enter Last.fm password">
</div>
<div class="config-item" style="grid-template-columns: 200px 1fr;">
<span class="label">Session Key</span>
<span class="value" id="lastfm-session-key-value" style="font-family: monospace; font-size: 0.85rem; word-break: break-all;">-</span>
</div>
<div class="config-item" style="grid-template-columns: 200px 1fr;">
<span class="label">Status</span>
<span class="value" id="lastfm-status-value">-</span>
</div>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button class="primary" onclick="authenticateLastFm()">Authenticate & Save</button>
<button onclick="testLastFmConnection()">Test Connection</button>
</div>
</div>
<div class="card">
<h2>ListenBrainz</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Scrobble to ListenBrainz. Get your user token from <a href="https://listenbrainz.org/settings/" target="_blank" style="color: var(--accent);">ListenBrainz Settings</a>.
<br><strong>Note:</strong> Only external tracks (Spotify, Deezer, Qobuz) are scrobbled to ListenBrainz. Local library tracks are not scrobbled.
</p>
<div class="config-section" style="margin-bottom: 24px;">
<div class="config-item">
<span class="label">ListenBrainz Enabled</span>
<input type="checkbox" id="listenbrainz-enabled-value" data-env-key="SCROBBLING_LISTENBRAINZ_ENABLED" class="config-input">
</div>
<div class="config-item">
<span class="label">User Token</span>
<input type="password" id="listenbrainz-token-value" data-env-key="SCROBBLING_LISTENBRAINZ_USER_TOKEN" class="config-input" placeholder="Enter ListenBrainz user token">
</div>
<div class="config-item" style="grid-template-columns: 200px 1fr;">
<span class="label">Status</span>
<span class="value" id="listenbrainz-status-value">-</span>
</div>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button class="primary" onclick="validateListenBrainzToken()">Validate & Save Token</button>
<button onclick="testListenBrainzConnection()">Test Connection</button>
</div>
</div>
<div class="card">
<h2>MusicBrainz Settings</h2>
<div class="config-section">
<div class="config-item">
<span class="label">Enabled</span>
<input type="checkbox" id="config-musicbrainz-enabled" data-env-key="MUSICBRAINZ_ENABLED" class="config-input">
</div>
<div class="config-item">
<span class="label">Username</span>
<input type="text" id="config-musicbrainz-username" data-env-key="MUSICBRAINZ_USERNAME" class="config-input">
</div>
<div class="config-item">
<span class="label">Password</span>
<input type="password" id="config-musicbrainz-password" data-env-key="MUSICBRAINZ_PASSWORD" class="config-input" placeholder="Enter MusicBrainz password">
</div>
</div>
</div>
<div class="save-bar-sticky">
<button class="primary" onclick="saveAllSettings('tab-services')">💾 Save Services Settings</button>
</div>
</div>
<!-- Configuration Tab -->
<div class="tab-content" id="tab-config">
<div class="card">
<h2>Core Settings</h2>
<div class="config-section">
<div class="config-item">
<span class="label">Backend Type <span style="color: var(--error);">*</span></span>
<select id="config-backend-type" data-env-key="BACKEND_TYPE" class="config-input">
<option value="Jellyfin">Jellyfin</option>
<option value="Subsonic">Subsonic</option>
</select>
</div>
<div class="config-item">
<span class="label">Music Service <span style="color: var(--error);">*</span></span>
<select id="config-music-service" data-env-key="MUSIC_SERVICE" class="config-input">
<option value="SquidWTF">SquidWTF (Tidal)</option>
<option value="Deezer">Deezer</option>
<option value="Qobuz">Qobuz</option>
<option value="AppleMusic">Apple Music</option>
</select>
</div>
<div class="config-item">
<span class="label">Storage Mode</span>
<select id="config-storage-mode" data-env-key="STORAGE_MODE" class="config-input">
<option value="Permanent">Permanent</option>
<option value="Cache">Cache</option>
</select>
</div>
<div class="config-item" id="cache-duration-row">
<span class="label">Cache Duration (hours)</span>
<input type="number" id="config-cache-duration-hours" data-env-key="CACHE_DURATION_HOURS" class="config-input" min="1">
</div>
<div class="config-item">
<span class="label">Download Mode</span>
<select id="config-download-mode" data-env-key="DOWNLOAD_MODE" class="config-input">
<option value="Track">Track</option>
<option value="Album">Album</option>
</select>
</div>
<div class="config-item">
<span class="label">Explicit Filter</span>
<select id="config-explicit-filter" data-env-key="EXPLICIT_FILTER" class="config-input">
<option value="All">All</option>
<option value="ExplicitOnly">ExplicitOnly</option>
<option value="CleanOnly">CleanOnly</option>
</select>
</div>
<div class="config-item">
<span class="label">Enable External Playlists</span>
<input type="checkbox" id="config-enable-external-playlists" data-env-key="ENABLE_EXTERNAL_PLAYLISTS" class="config-input">
</div>
<div class="config-item">
<span class="label">Playlists Directory</span>
<input type="text" id="config-playlists-directory" data-env-key="PLAYLISTS_DIRECTORY" class="config-input" placeholder="e.g. /app/playlists">
</div>
<div class="config-item">
<span class="label">Redis Enabled</span>
<input type="checkbox" id="config-redis-enabled" data-env-key="REDIS_ENABLED" class="config-input">
</div>
</div>
</div>
<style>
.priority-list {
list-style-type: none;
padding: 0;
margin: 10px 0;
}
.priority-item {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
padding: 12px 16px;
margin-bottom: 8px;
cursor: grab;
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color 0.2s, transform 0.2s, border-color 0.2s;
}
.priority-item:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.15);
}
.priority-item:active {
cursor: grabbing;
}
.priority-item.dragging {
opacity: 0.4;
background: rgba(255, 255, 255, 0.08);
transform: scale(0.98);
}
.priority-item-left {
display: flex;
align-items: center;
}
.priority-item-drag-handle {
margin-right: 12px;
color: rgba(255, 255, 255, 0.3);
font-size: 1.2rem;
cursor: grab;
user-select: none;
}
.status-indicator {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 6px;
}
.status-indicator.healthy {
background-color: var(--success, #10b981);
box-shadow: 0 0 8px rgba(16, 185, 129, 0.5);
}
.status-indicator.unhealthy {
background-color: var(--error, #ef4444);
box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
}
.status-indicator.testing {
background-color: var(--warning, #f59e0b);
animation: pulse 1s infinite alternate;
}
@keyframes pulse {
from { opacity: 0.5; }
to { opacity: 1; }
}
</style>
<div class="card" id="multi-provider-card">
<input type="hidden" id="config-multi-provider-metadata-order" data-env-key="MULTI_PROVIDER_METADATA_ORDER" class="config-input">
<input type="hidden" id="config-multi-provider-download-order" data-env-key="MULTI_PROVIDER_DOWNLOAD_ORDER" class="config-input">
<input type="hidden" id="config-multi-provider-playlist-order" data-env-key="MULTI_PROVIDER_PLAYLIST_ORDER" class="config-input">
<input type="hidden" id="config-multi-provider-lyrics-order" data-env-key="MULTI_PROVIDER_LYRICS_ORDER" class="config-input">
<input type="hidden" id="config-multi-provider-enabled-search" data-env-key="MULTI_PROVIDER_ENABLED_SEARCH" class="config-input">
<input type="hidden" id="config-multi-provider-enabled-playlist" data-env-key="MULTI_PROVIDER_ENABLED_PLAYLIST" class="config-input">
<h2>Multi-Service Providers & Priority</h2>
<div class="description-text" style="color: var(--text-secondary); margin-bottom: 16px; font-size: 0.9rem;">
Configure and prioritize multiple streaming services. Drag and drop to change metadata ranking priority.
</div>
<div class="table-scroll" style="margin-bottom: 24px;">
<table class="playlist-table" id="providers-table" style="width: 100%; border-collapse: collapse;">
<thead>
<tr>
<th style="text-align: left; padding: 10px;">Service</th>
<th style="text-align: center; padding: 10px;">Search & Metadata</th>
<th style="text-align: center; padding: 10px;">Playlist Sync</th>
<th style="text-align: center; padding: 10px;">Status</th>
<th style="text-align: center; padding: 10px;">Actions</th>
</tr>
</thead>
<tbody id="providers-tbody">
<!-- Populated dynamically via JS -->
</tbody>
</table>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 20px; margin-top: 20px;">
<div>
<h4 style="margin: 0 0 8px 0; color: #fff;">1. Metadata Search Priority</h4>
<div class="description-text" style="color: var(--text-secondary); margin-bottom: 12px; font-size: 0.8rem; line-height: 1.3;">
Priority for search results.
</div>
<ul id="provider-priority-list" class="priority-list" style="list-style-type: none; padding: 0; margin: 0;">
<!-- Populated dynamically via JS -->
</ul>
</div>
<div>
<h4 style="margin: 0 0 8px 0; color: #fff;">2. Download & Playback Priority</h4>
<div class="description-text" style="color: var(--text-secondary); margin-bottom: 12px; font-size: 0.8rem; line-height: 1.3;">
Priority for downloads/streaming.
</div>
<ul id="download-priority-list" class="priority-list" style="list-style-type: none; padding: 0; margin: 0;">
<!-- Populated dynamically via JS -->
</ul>
</div>
<div>
<h4 style="margin: 0 0 8px 0; color: #fff;">3. Playlist Sync Priority</h4>
<div class="description-text" style="color: var(--text-secondary); margin-bottom: 12px; font-size: 0.8rem; line-height: 1.3;">
Priority for playlist matching.
</div>
<ul id="playlist-priority-list" class="priority-list" style="list-style-type: none; padding: 0; margin: 0;">
<!-- Populated dynamically via JS -->
</ul>
</div>
<div>
<h4 style="margin: 0 0 8px 0; color: #fff;">4. Lyrics Sourcing Priority</h4>
<div class="description-text" style="color: var(--text-secondary); margin-bottom: 12px; font-size: 0.8rem; line-height: 1.3;">
Priority for lyrics fetchers.
</div>
<ul id="lyrics-priority-list" class="priority-list" style="list-style-type: none; padding: 0; margin: 0;">
<!-- Populated dynamically via JS -->
</ul>
</div>
</div>
</div>
<div class="card">
<h2>Admin Network Settings</h2>
<div style="background: rgba(245, 158, 11, 0.12); border: 1px solid var(--warning); border-radius: 6px; padding: 12px; margin-bottom: 16px; color: var(--text-secondary); font-size: 0.9rem;">
Keep admin UI on localhost by default. If you enable LAN bind, restrict access with trusted CIDR
subnets (for example: <code>192.168.1.0/24,10.0.0.0/8</code>).
</div>
<div class="config-section">
<div class="config-item">
<span class="label">Bind Admin UI On LAN</span>
<input type="checkbox" id="config-admin-bind-any-ip" data-env-key="ADMIN_BIND_ANY_IP" class="config-input">
</div>
<div class="config-item">
<span class="label">Trusted Subnets (CIDR)</span>
<input type="text" id="config-admin-trusted-subnets" data-env-key="ADMIN_TRUSTED_SUBNETS" class="config-input" placeholder="e.g. 192.168.1.0/24, 10.0.0.0/8">
</div>
</div>
</div>
<div class="card">
<details class="advanced-section">
<summary>Advanced: Debug Settings</summary>
<div class="advanced-section-content" style="padding-top: 12px;">
<div class="config-section">
<div class="config-item">
<span class="label">Log All Requests</span>
<input type="checkbox" id="config-debug-log-requests" data-env-key="DEBUG_LOG_ALL_REQUESTS" class="config-input">
</div>
<div class="guidance-banner info compact" style="margin-top: 12px;">
ℹ️ When enabled, logs every incoming request with method, path, headers, and response status. Auth tokens are automatically masked.
</div>
</div>
</div>
</details>
</div>
<div class="card">
<h2>Jellyfin Settings</h2>
<div class="config-section">
<div class="config-item">
<span class="label">URL <span style="color: var(--error);">*</span></span>
<input type="text" id="config-jellyfin-url" data-env-key="JELLYFIN_URL" class="config-input" placeholder="http://192.168.1.100:8096">
</div>
<div class="config-item">
<span class="label">API Key <span style="color: var(--error);">*</span></span>
<input type="password" id="config-jellyfin-api-key" data-env-key="JELLYFIN_API_KEY" class="config-input" placeholder="Enter Jellyfin API key">
</div>
<div class="config-item">
<span class="label">User ID <span style="color: var(--error);">*</span></span>
<input type="text" id="config-jellyfin-user-id" data-env-key="JELLYFIN_USER_ID" class="config-input" placeholder="Enter user profile ID">
</div>
</div>
</div>
<div class="card">
<h2>Library Settings</h2>
<div class="config-section">
<div class="config-item">
<span class="label">Download Path (Cache)</span>
<input type="text" id="config-download-path" data-env-key="LIBRARY_DOWNLOAD_PATH" class="config-input">
</div>
<div class="config-item">
<span class="label">Kept Path (Favorited)</span>
<input type="text" id="config-kept-path" data-env-key="LIBRARY_KEPT_PATH" class="config-input">
</div>
</div>
</div>
<div class="card">
<h2>Spotify Import Settings</h2>
<div class="config-section">
<div class="config-item">
<span class="label">Spotify Import Enabled</span>
<input type="checkbox" id="config-spotify-import-enabled" data-env-key="SPOTIFY_IMPORT_ENABLED" class="config-input">
</div>
<div class="config-item">
<span class="label">Matching Interval (hours)</span>
<input type="number" id="config-matching-interval" data-env-key="SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS" class="config-input" min="1">
</div>
</div>
</div>
<div class="card">
<h2>Cache Settings</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Configure how long different types of data are cached. Longer durations reduce API calls but may
show stale data.
</p>
<div class="config-section">
<div class="config-item">
<span class="label">Search Results (minutes)</span>
<input type="number" id="config-cache-search" data-env-key="CACHE_SEARCH_RESULTS_MINUTES" class="config-input" min="1">
</div>
<div class="config-item">
<span class="label">Playlist Images (hours)</span>
<input type="number" id="config-cache-playlist-images" data-env-key="CACHE_PLAYLIST_IMAGES_HOURS" class="config-input" min="1">
</div>
<div class="config-item">
<span class="label">Spotify Playlist Items (hours)</span>
<input type="number" id="config-cache-spotify-items" data-env-key="CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS" class="config-input" min="1">
</div>
<div class="config-item">
<span class="label">Spotify Matched Tracks (days)</span>
<input type="number" id="config-cache-matched-tracks" data-env-key="CACHE_SPOTIFY_MATCHED_TRACKS_DAYS" class="config-input" min="1">
</div>
<div class="config-item">
<span class="label">Lyrics (days)</span>
<input type="number" id="config-cache-lyrics" data-env-key="CACHE_LYRICS_DAYS" class="config-input" min="1">
</div>
<div class="config-item">
<span class="label">Genre Data (days)</span>
<input type="number" id="config-cache-genres" data-env-key="CACHE_GENRE_DAYS" class="config-input" min="1">
</div>
<div class="config-item">
<span class="label">External Metadata (days)</span>
<input type="number" id="config-cache-metadata" data-env-key="CACHE_METADATA_DAYS" class="config-input" min="1">
</div>
<div class="config-item">
<span class="label">Odesli Lookups (days)</span>
<input type="number" id="config-cache-odesli" data-env-key="CACHE_ODESLI_LOOKUP_DAYS" class="config-input" min="1">
</div>
<div class="config-item">
<span class="label">Proxy Images (days)</span>
<input type="number" id="config-cache-proxy-images" data-env-key="CACHE_PROXY_IMAGES_DAYS" class="config-input" min="1">
</div>
</div>
</div>
<div class="card" id="config-backup-card">
<h2>Configuration Backup</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;" id="config-backup-description">
Export your .env configuration for backup or import a previously saved configuration.
</p>
<p style="color: var(--warning); margin-bottom: 12px; display: none;" id="export-env-disabled-hint">
.env export is disabled by default for security.
</p>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button id="export-env-btn" onclick="exportEnv()">📥 Export .env</button>
<button onclick="document.getElementById('import-env-input').click()">📤 Import .env</button>
<input type="file" id="import-env-input" accept=".env" style="display:none"
onchange="importEnv(event)">
</div>
</div>
<div class="card" style="background: rgba(248, 81, 73, 0.1); border-color: var(--error);">
<h2 style="color: var(--error);">Danger Zone</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
These actions can affect your data. Use with caution.
</p>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button class="danger" onclick="clearCache()">Clear All Cache</button>
<button class="danger" onclick="restartContainer()">Restart Allstarr</button>
</div>
</div>
<div class="save-bar-sticky">
<button class="primary" onclick="saveAllSettings('tab-config')">💾 Save Configuration</button>
</div>
</div>
<!-- Extensions Tab -->
<div class="tab-content" id="tab-extensions">
<input type="hidden" id="config-extension-repositories" data-env-key="EXTENSION_REPOSITORIES" class="config-input">
<div class="card">
<h2>Community Extensions (.spotiflac-ext)</h2>
<div class="description-text" style="color: var(--text-secondary); margin-bottom: 16px; font-size: 0.9rem;">
Natively load and run community-created SpotiFLAC extensions in a secure sandbox! These plugins extend Allstarr's search, lyrics, and metadata capabilities without requiring premium credentials.
</div>
<div style="font-size: 0.85rem; color: var(--text-secondary); background: rgba(255,255,255,0.05); padding: 12px; border-radius: 6px; margin-bottom: 20px; border-left: 3px solid var(--accent-color);">
💡 <strong>Where to find extensions:</strong> You can enter any SpotiFLAC Extension Store registry link (such as the official repository <code>https://raw.githubusercontent.com/spotiflacapp/SpotiFLAC-Extension/main/registry.json</code>) to populate the store below.
</div>
</div>
<!-- Repository Configuration Card -->
<div class="card">
<h3>Extension Registries</h3>
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
<input type="text" id="new-repo-url" placeholder="https://raw.githubusercontent.com/.../registry.json" style="flex: 1; padding: 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.15); background: rgba(0,0,0,0.2); color: #fff;">
<button class="primary" onclick="addExtensionRepo()" style="padding: 10px 16px;">Add Registry</button>
</div>
<ul id="configured-repos-list" style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px;">
<!-- Configured repos will be listed here -->
</ul>
</div>
<!-- Extension Store Card -->
<div class="card">
<h3>Extension Store</h3>
<div class="description-text" style="color: var(--text-secondary); margin-bottom: 16px; font-size: 0.85rem;">
Browse and install community plugins from your registered registries.
</div>
<div id="store-extensions-list" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px;">
<!-- Extensions from store will be populated here -->
</div>
</div>
<!-- Installed Extensions Card -->
<div class="card">
<h3>Installed Extensions</h3>
<div class="description-text" style="color: var(--text-secondary); margin-bottom: 16px; font-size: 0.85rem;">
Currently active and running JavaScript sandboxes.
</div>
<div id="installed-extensions-list" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px;">
<!-- Installed extensions will be listed here -->
</div>
</div>
</div>
<!-- Report Issues Tab -->
<div class="tab-content" id="tab-report-issues">
<div class="card">
<h2>Report Issues</h2>
<div class="guidance-banner info mb-16">
<span>ℹ️</span>
<div class="guidance-content">
<div class="guidance-title">Draft a GitHub issue from inside Allstarr.</div>
<div class="guidance-detail">Allstarr includes only safe diagnostics here. Sensitive values stay redacted, and the final submit still happens on GitHub.</div>
</div>
</div>
<div class="report-issue-layout">
<div class="report-issue-panel">
<div class="form-group">
<label for="issue-report-type">Report Type</label>
<select id="issue-report-type">
<option value="bug">Bug Report</option>
<option value="feature">Feature Request</option>
</select>
</div>
<div class="form-group">
<label for="issue-report-title">Title</label>
<input type="text" id="issue-report-title" placeholder="Short summary of the issue">
</div>
<div class="form-group">
<label for="issue-report-primary" id="issue-report-primary-label">Describe the bug</label>
<textarea id="issue-report-primary" rows="5"
placeholder="What happened? What looked wrong?"></textarea>
</div>
<div class="form-group">
<label for="issue-report-secondary" id="issue-report-secondary-label">To Reproduce</label>
<textarea id="issue-report-secondary" rows="5"
placeholder="List the steps needed to reproduce the issue"></textarea>
</div>
<div class="form-group">
<label for="issue-report-tertiary" id="issue-report-tertiary-label">Expected behavior</label>
<textarea id="issue-report-tertiary" rows="4"
placeholder="What did you expect to happen instead?"></textarea>
</div>
<div class="form-group">
<label for="issue-report-context" id="issue-report-context-label">Additional context</label>
<textarea id="issue-report-context" rows="4"
placeholder="Anything else that might help, including screenshots or surrounding context"></textarea>
</div>
<div class="card-actions-row">
<button class="primary" type="button" id="open-github-issue-btn">Open Bug Report on GitHub</button>
<button type="button" id="copy-issue-report-btn">Copy Report</button>
<button type="button" id="clear-issue-report-btn">Clear Report</button>
</div>
</div>
<div class="report-preview-panel">
<div class="guidance-banner compact">
Safe diagnostics only: version, runtime config, service state, and a concise client summary. Sensitive values stay redacted.
</div>
<div class="form-group">
<label for="issue-report-preview">GitHub Issue Preview</label>
<textarea id="issue-report-preview" rows="22" readonly></textarea>
</div>
<div class="report-preview-help" id="issue-report-preview-help">
GitHub drafts that exceed the URL size limit will open with a shorter body. The full report will also be copied to your clipboard.
</div>
</div>
</div>
</div>
</div>
<!-- API Analytics Tab -->
<div class="tab-content" id="tab-endpoints">
<div class="card">
<h2>
API Endpoint Usage
<div class="actions">
<button onclick="fetchEndpointUsage()">Refresh</button>
<button class="danger" onclick="clearEndpointUsage()">Clear Data</button>
</div>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Track which Jellyfin API endpoints are being called most frequently. Useful for debugging and
understanding client behavior.
</p>
<div id="endpoints-summary"
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 20px;">
<div style="background: var(--bg-tertiary); padding: 16px; border-radius: 8px;">
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 4px;">Total
Requests</div>
<div style="font-size: 1.8rem; font-weight: 600; color: var(--accent);"
id="endpoints-total-requests">0</div>
</div>
<div style="background: var(--bg-tertiary); padding: 16px; border-radius: 8px;">
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 4px;">Unique
Endpoints</div>
<div style="font-size: 1.8rem; font-weight: 600; color: var(--success);"
id="endpoints-unique-count">0</div>
</div>
<div style="background: var(--bg-tertiary); padding: 16px; border-radius: 8px;">
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 4px;">Most Called
</div>
<div style="font-size: 1.1rem; font-weight: 600; color: var(--text-primary); word-break: break-all;"
id="endpoints-most-called">-</div>
</div>
</div>
<div style="margin-bottom: 16px;">
<label
style="display: block; margin-bottom: 8px; color: var(--text-secondary); font-size: 0.9rem;">Show
Top</label>
<select id="endpoints-top-select" onchange="fetchEndpointUsage()"
style="padding: 8px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);">
<option value="25">Top 25</option>
<option value="50" selected>Top 50</option>
<option value="100">Top 100</option>
<option value="500">Top 500</option>
</select>
</div>
<div style="max-height: 600px; overflow-y: auto;">
<table class="playlist-table">
<thead>
<tr>
<th style="width: 60px;">#</th>
<th>Endpoint</th>
<th style="width: 120px; text-align: right;">Requests</th>
<th style="width: 120px; text-align: right;">% of Total</th>
</tr>
</thead>
<tbody id="endpoints-table-body">
<tr>
<td colspan="4" class="loading">
<span class="spinner"></span> Loading endpoint usage data...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<h2>About Endpoint Tracking</h2>
<p style="color: var(--text-secondary); line-height: 1.6;">
Allstarr logs every Jellyfin API endpoint call to help you understand how clients interact with your
server.
This data is stored in <code
style="background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px;">/app/cache/endpoint-usage/endpoints.csv</code>
and persists across restarts.
<br><br>
<strong>Common Endpoints:</strong>
<ul style="margin-top: 8px; margin-left: 20px;">
<li><code>/Users/{userId}/Items</code> - Browse library items</li>
<li><code>/Items/{itemId}</code> - Get item details</li>
<li><code>/Audio/{itemId}/stream</code> - Stream audio</li>
<li><code>/Sessions/Playing</code> - Report playback status</li>
<li><code>/Search/Hints</code> - Search functionality</li>
</ul>
</p>
</div>
</div>
<footer class="support-footer">
<p class="support-text">
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
supporting its development
</p>
<ul class="support-funding-icons">
<li>
<a class="support-funding-link" href="https://ko-fi.com/joshpatra" target="_blank"
rel="noopener noreferrer" aria-label="Support on Ko-fi">
<img src="images/kofi_symbol.svg" alt="" width="37" height="30" decoding="async" />
</a>
</li>
<li>
<a class="support-funding-link" href="https://github.com/sponsors/SoPat712" target="_blank"
rel="noopener noreferrer" aria-label="GitHub Sponsors">
<img src="images/github-mark.svg" alt="" width="30" height="29" decoding="async" />
</a>
</li>
<li>
<a class="support-funding-link" href="https://buymeacoffee.com/treeman183" target="_blank"
rel="noopener noreferrer" aria-label="Buy Me a Coffee">
<img src="images/buymeacoffee-symbol.svg" alt="" width="30" height="30" decoding="async" />
</a>
</li>
</ul>
</footer>
</main>
</div>
</div>
<!-- Add Playlist Modal -->
<div class="modal" id="add-playlist-modal">
<div class="modal-content">
<h3>Add Playlist</h3>
<div class="form-group">
<label>Playlist Name</label>
<input type="text" id="new-playlist-name" placeholder="e.g., Release Radar">
</div>
<div class="form-group">
<label>Spotify Playlist ID</label>
<input type="text" id="new-playlist-id" placeholder="Get from Spotify Import plugin">
</div>
<div class="modal-actions">
<button onclick="closeModal('add-playlist-modal')">Cancel</button>
<button class="primary" onclick="addPlaylist()">Add Playlist</button>
</div>
</div>
</div>
<!-- Edit Setting Modal -->
<div class="modal" id="edit-setting-modal">
<div class="modal-content">
<h3 id="edit-setting-title">Edit Setting</h3>
<p id="edit-setting-help" style="color: var(--text-secondary); margin-bottom: 16px; display: none;"></p>
<div class="form-group">
<label id="edit-setting-label">Value</label>
<div id="edit-setting-input-container">
<input type="text" id="edit-setting-value" placeholder="Enter value">
</div>
</div>
<div class="modal-actions">
<button onclick="closeModal('edit-setting-modal')">Cancel</button>
<button class="primary" onclick="saveEditSetting()">Save</button>
</div>
</div>
</div>
<!-- Track List Modal -->
<div class="modal" id="tracks-modal">
<div class="modal-content" style="max-width: 90%; width: 90%;">
<h3 id="tracks-modal-title">Playlist Tracks</h3>
<div class="tracks-list" id="tracks-list">
<div class="loading">
<span class="spinner"></span> Loading tracks...
</div>
</div>
<div class="modal-actions">
<button onclick="closeModal('tracks-modal')">Close</button>
</div>
</div>
</div>
<!-- Manual Track Mapping Modal -->
<div class="modal" id="manual-map-modal">
<div class="modal-content" style="max-width: 760px; width: min(94vw, 760px);">
<h3>Map Track to External Provider</h3>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the
Jellyfin mapping modal instead.
</p>
<!-- Track Info -->
<div class="form-group">
<label>Spotify Track (Position <span id="map-position"></span>)</label>
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
<strong id="map-spotify-title"></strong><br>
<span style="color: var(--text-secondary);" id="map-spotify-artist"></span>
</div>
</div>
<!-- External Mapping Section -->
<div id="external-mapping-section">
<div class="form-group">
<label>External Provider</label>
<select id="map-external-provider" style="width: 100%;">
<option value="SquidWTF">SquidWTF</option>
<option value="Deezer">Deezer</option>
<option value="Qobuz">Qobuz</option>
<option value="AppleMusic">Apple Music</option>
</select>
</div>
<div class="form-group">
<label>Search External Provider</label>
<input type="text" id="map-external-search" placeholder="Search for track name or artist...">
<button onclick="searchExternalTracks()" style="margin-top: 8px; width: 100%;">🔍 Search</button>
</div>
<div id="map-external-results" style="max-height: 240px; overflow-y: auto; margin-top: 8px; margin-bottom: 12px;">
<p style="color: var(--text-secondary); text-align: center; padding: 20px;">Enter search terms and click Search</p>
</div>
<div class="form-group">
<label>External Provider ID</label>
<input type="text" id="map-external-id" placeholder="Enter the provider-specific track ID..."
oninput="validateExternalMapping()">
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
For SquidWTF: Use a numeric track ID or full track URL<br>
For Deezer: Use the track ID from Deezer URLs<br>
For Qobuz: Use the track ID from Qobuz URLs
</small>
</div>
</div>
<input type="hidden" id="map-playlist-name">
<input type="hidden" id="map-spotify-id">
<div class="modal-actions">
<button onclick="closeModal('manual-map-modal')">Cancel</button>
<button class="primary" onclick="saveManualMapping()" id="map-save-btn" disabled>Save Mapping</button>
</div>
</div>
</div>
<!-- Local Jellyfin Track Mapping Modal -->
<div class="modal" id="local-map-modal">
<div class="modal-content" style="max-width: 700px;">
<h3>Map Track to Local Jellyfin Track</h3>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Search your Jellyfin library and select a local track to map to this Spotify track.
</p>
<!-- Track Info -->
<div class="form-group">
<label>Spotify Track (Position <span id="local-map-position"></span>)</label>
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
<strong id="local-map-spotify-title"></strong><br>
<span style="color: var(--text-secondary);" id="local-map-spotify-artist"></span>
</div>
</div>
<!-- Search Section -->
<div class="form-group">
<label>Search Jellyfin Library</label>
<input type="text" id="local-map-search" placeholder="Search for track name or artist...">
<button onclick="searchJellyfinTracks()" style="margin-top: 8px; width: 100%;">🔍 Search</button>
</div>
<!-- Search Results -->
<div id="local-map-results" style="max-height: 300px; overflow-y: auto; margin-top: 16px;"></div>
<input type="hidden" id="local-map-playlist-name">
<input type="hidden" id="local-map-spotify-id">
<input type="hidden" id="local-map-jellyfin-id">
<div class="modal-actions">
<button onclick="closeModal('local-map-modal')">Cancel</button>
<button class="primary" onclick="saveLocalMapping()" id="local-map-save-btn" disabled>Save
Mapping</button>
</div>
</div>
</div>
<!-- Link Playlist Modal -->
<div class="modal" id="link-playlist-modal">
<div class="modal-content">
<h3>Link to Spotify Playlist</h3>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Select a playlist from your Spotify library or enter a playlist ID/URL manually. Allstarr will
automatically download missing tracks from your configured music service.
</p>
<div class="form-group">
<label>Jellyfin Playlist</label>
<input type="text" id="link-jellyfin-name" readonly style="background: var(--bg-primary);">
<input type="hidden" id="link-jellyfin-id">
</div>
<!-- Toggle between select and manual input -->
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
<button type="button" id="select-mode-btn" class="primary" onclick="switchLinkMode('select')"
style="flex: 1;">Select from My Playlists</button>
<button type="button" id="manual-mode-btn" onclick="switchLinkMode('manual')" style="flex: 1;">Enter
Manually</button>
</div>
<!-- Select from user playlists -->
<div class="form-group" id="link-select-group">
<label>Your Spotify Playlists</label>
<select id="link-spotify-select" style="width: 100%;">
<option value="">Loading playlists...</option>
</select>
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Select a playlist from your Spotify library
</small>
</div>
<!-- Manual input -->
<div class="form-group" id="link-manual-group" style="display: none;">
<label>Spotify Playlist ID or URL</label>
<input type="text" id="link-spotify-id"
placeholder="37i9dQZF1DXcBWIGoYBM5M or spotify:playlist:... or full URL">
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Accepts: <code>37i9dQZF1DXcBWIGoYBM5M</code>, <code>spotify:playlist:37i9dQZF1DXcBWIGoYBM5M</code>,
or full Spotify URL
</small>
</div>
<!-- Sync Schedule -->
<div class="form-group">
<label>Sync Schedule (Cron)</label>
<input type="text" id="link-sync-schedule" placeholder="0 8 * * *" value="0 8 * * *"
style="font-family: monospace;">
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Cron format: <code>minute hour day month dayofweek</code><br>
Default: <code>0 8 * * *</code> = 8 AM every day<br>
Examples: <code>0 6 * * *</code> = daily at 6 AM, <code>0 20 * * 5</code> = Fridays at 8 PM<br>
<a href="https://crontab.guru/" target="_blank" style="color: var(--primary);">Use crontab.guru to
build your schedule</a>
</small>
</div>
<div class="modal-actions">
<button onclick="closeModal('link-playlist-modal')">Cancel</button>
<button class="primary" onclick="linkPlaylist()">Link Playlist</button>
</div>
</div>
</div>
<!-- Lyrics ID Mapping Modal -->
<div class="modal" id="lyrics-map-modal">
<div class="modal-content" style="max-width: 600px;">
<h3>Map Lyrics ID</h3>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Manually map a track to a specific lyrics ID from lrclib.net. You can find lyrics IDs by searching on <a
href="https://lrclib.net" target="_blank" style="color: var(--accent);">lrclib.net</a>.
</p>
<!-- Track Info -->
<div class="form-group">
<label>Track</label>
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
<strong id="lyrics-map-title"></strong><br>
<span style="color: var(--text-secondary);" id="lyrics-map-artist"></span><br>
<small style="color: var(--text-secondary);" id="lyrics-map-album"></small>
</div>
</div>
<!-- Lyrics ID Input -->
<div class="form-group">
<label>Lyrics ID from lrclib.net</label>
<input type="number" id="lyrics-map-id" placeholder="Enter lyrics ID (e.g., 5929990)" min="1">
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Search for the track on <a href="https://lrclib.net" target="_blank"
style="color: var(--accent);">lrclib.net</a> and copy the ID from the URL or API response
</small>
</div>
<input type="hidden" id="lyrics-map-artist-value">
<input type="hidden" id="lyrics-map-title-value">
<input type="hidden" id="lyrics-map-album-value">
<input type="hidden" id="lyrics-map-duration">
<div class="modal-actions">
<button onclick="closeModal('lyrics-map-modal')">Cancel</button>
<button class="primary" onclick="saveLyricsMapping()" id="lyrics-map-save-btn">Save Mapping</button>
</div>
</div>
</div>
<!-- Restart Overlay -->
<div class="restart-overlay" id="restart-overlay">
<div class="spinner-large"></div>
<h2>Restarting Container</h2>
<p id="restart-status">Applying configuration changes...</p>
</div>
<script type="module" src="js/main.js"></script>
<allstarr-app></allstarr-app>
<script type="module" src="/js/webui.js"></script>
</body>
</html>
+1602
View File
@@ -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`<div class="app-loading"><div class="chip">Loading Allstarr</div></div>`;
}
if (!this.authenticated) {
return this.renderAuth();
}
return html`
<div class="app-shell">
${this.renderSidebar()}
<div class="main-shell">
${this.renderTopbar()}
<main class="content">${this.renderRoute()}</main>
</div>
</div>
${this.renderRestartBar()}
${this.renderNowPlaying()}
${this.renderToasts()}
`;
}
renderAuth() {
return html`
<section class="auth-screen">
<div class="auth-card">
<h1>Allstarr</h1>
<p>Sign in with a Jellyfin account to manage this server.</p>
<form class="form-stack" @submit=${this.login}>
<div class="form-row">
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username" required>
</div>
<div class="form-row">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
</div>
<label class="inline-check">
<input name="remember" type="checkbox">
<span>Keep me signed in</span>
</label>
<button class="primary" type="submit">Sign in</button>
<div class="auth-error" role="alert">${this.loginError}</div>
</form>
</div>
</section>
`;
}
renderSidebar() {
const routes = asArray(this.schema?.routes);
return html`
<aside class="sidebar ${this.navOpen ? "open" : ""}">
<div class="brand">
<a class="brand-title" href="#/home">Allstarr</a>
<div class="brand-subtitle">${display(this.status?.version || this.status?.Version, "Media manager")}</div>
<span class="status-chip configured">${display(this.schema?.activeBackend || this.config?.backendType)}</span>
</div>
<nav class="nav-list" aria-label="Primary">
${routes.map((route) => html`
<a class="nav-link ${this.isRouteActive(route.path) ? "active" : ""}" href=${route.path}>
<span>${route.label}</span>
</a>
`)}
</nav>
<div class="sidebar-footer">
<div>Signed in as <strong>${display(this.session?.name || this.session?.Name)}</strong></div>
<button class="ghost" @click=${this.logout}>Logout</button>
</div>
</aside>
`;
}
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`
<header class="topbar">
<div>
<button class="mobile-menu ghost" @click=${() => { this.navOpen = true; }}>Menu</button>
<h1>${titleCase(zone || "home")}${sub ? html` <span class="muted">/ ${titleCase(sub)}</span>` : nothing}</h1>
<div class="topbar-meta">${display(this.status?.spotify?.authStatus || this.status?.Spotify?.AuthStatus, "status unknown")}</div>
</div>
<div class="actions">
<select aria-label="Theme" .value=${this.theme} @change=${(event) => this.setTheme(event.target.value)}>
<option value="system">System</option>
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
<button class="ghost" @click=${async () => { await Promise.all([this.loadStatus(), this.loadConfig()]); this.toast("Status refreshed"); }}>Refresh</button>
</div>
</header>
`;
}
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`
<section class="view-stack">
<div class="view-header">
<div>
<h2>Home</h2>
<p>Runtime state, provider readiness, and current activity.</p>
</div>
<div class="actions">
<button @click=${async () => { await API.refreshPlaylists(); this.toast("Playlist refresh requested"); }}>Refresh playlists</button>
<button @click=${async () => { await API.clearCache(); this.toast("Cache clear requested"); }}>Clear cache</button>
<button class="primary" @click=${async () => { await API.restart(); this.toast("Restart requested"); }}>Restart</button>
</div>
</div>
<div class="grid">
<div class="card metric">
<span class="metric-label">Backend</span>
<span class="metric-value">${display(this.status?.backendType || this.config?.backendType)}</span>
</div>
<div class="card metric">
<span class="metric-label">Spotify</span>
<span class="metric-value">${titleCase(spotify.authStatus || "unknown")}</span>
</div>
<div class="card metric">
<span class="metric-label">Injected playlists</span>
<span class="metric-value">${display(spotifyImport.playlistCount ?? this.config?.spotifyImport?.playlists?.length ?? 0)}</span>
</div>
<div class="card metric">
<span class="metric-label">Active tasks</span>
<span class="metric-value">${this.activity.length}</span>
</div>
</div>
<div class="wide-grid">
<div class="panel">
<h3>Setup</h3>
<div class="stat-list">
${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))}
</div>
</div>
<div class="panel">
<h3>Provider health</h3>
<div class="stat-list">
${providerCards.map((provider) => html`
<div class="stat-row">
<span>${provider.name}</span>
<span class="status-chip ${provider.status}">${titleCase(provider.status)}</span>
</div>
`)}
</div>
</div>
</div>
<div class="panel">
<h3>Activity feed</h3>
${this.renderActivityList(this.activity.slice(0, 8))}
</div>
</section>
`;
}
renderSetupStep(label, complete) {
return html`
<div class="stat-row">
<span>${label}</span>
<span class="chip ${complete ? "success" : "warning"}">${complete ? "Ready" : "Needs setup"}</span>
</div>
`;
}
renderLibrary() {
const [, sub = "overview"] = routeParts(this.route);
return html`
<section class="view-stack">
<div class="view-header">
<div>
<h2>Library</h2>
<p>Backend playlists, injected playlists, mappings, missing tracks, and kept files.</p>
</div>
</div>
${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()}
</section>
`;
}
renderLibraryNav(active) {
const items = [
["overview", "Overview"],
["link", "Link playlists"],
["injected", "Injected"],
["mappings", "Mappings"],
["missing", "Missing"],
["migration", "Migration"],
["kept", "Kept"],
["external", "External playlists"],
];
return html`
<nav class="subnav">
${items.map(([id, label]) => html`<a class=${active === id ? "active" : ""} href="#/library/${id}">${label}</a>`)}
</nav>
`;
}
renderLibraryOverview() {
return html`
<div class="grid">
<button class="card" @click=${() => this.navigate("/library/link")}>
<h3>Link playlists</h3>
<p class="muted">Connect backend playlists to Spotify playlist sources.</p>
</button>
<button class="card" @click=${() => this.navigate("/library/mappings")}>
<h3>Track mappings</h3>
<p class="muted">Manage manual Spotify to local or external mappings.</p>
</button>
<button class="card" @click=${() => this.navigate("/library/external")}>
<h3>External playlists</h3>
<p class="muted">Browse provider playlists from Deezer, Qobuz, SquidWTF, and Apple Music.</p>
</button>
<button class="card" @click=${() => this.navigate("/library/kept")}>
<h3>Kept downloads</h3>
<p class="muted">Review permanent downloads and archives.</p>
</button>
</div>
`;
}
renderLinkPlaylists() {
const playlists = asArray(this.jellyfinPlaylists?.playlists || this.jellyfinPlaylists?.Playlists);
const spotifyPlaylists = asArray(this.spotifyPlaylists?.playlists || this.spotifyPlaylists?.Playlists || this.spotifyPlaylists);
return html`
<div class="panel">
<div class="toolbar">
${this.jellyfinUsers.length ? html`
<div class="form-row">
<label for="jellyfin-user-filter">User</label>
<select id="jellyfin-user-filter" @change=${() => this.loadLinkData()}>
<option value="">All users</option>
${this.jellyfinUsers.map((user) => html`<option value=${user.id || user.Id}>${user.name || user.Name}</option>`)}
</select>
</div>
` : nothing}
<button class="primary" @click=${() => this.loadLinkData()}>Refresh</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Backend playlist</th>
<th>Tracks</th>
<th>Status</th>
<th>Spotify source</th>
<th></th>
</tr>
</thead>
<tbody>
${playlists.length ? playlists.map((playlist) => this.renderLinkPlaylistRow(playlist, spotifyPlaylists)) : html`
<tr><td colspan="5"><div class="empty">No backend playlists loaded.</div></td></tr>
`}
</tbody>
</table>
</div>
`;
}
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`
<tr>
<td><strong>${name}</strong></td>
<td>${display(playlist.trackCount ?? playlist.TrackCount ?? playlist.childCount ?? playlist.ChildCount)}</td>
<td><span class="status-chip ${linked ? "configured" : "needs_config"}">${linked ? "Linked" : "Unlinked"}</span></td>
<td>
${linked ? html`<span class="mono">${display(playlist.linkedSpotifyId || playlist.LinkedSpotifyId)}</span>` : html`
<select @change=${(event) => { this.linkSelections.set(id, event.target.value); this.requestUpdate(); }}>
<option value="">Select playlist</option>
${spotifyPlaylists.map((item) => html`
<option value=${item.id || item.Id || item.spotifyId || item.SpotifyId} ?selected=${selection === (item.id || item.Id)}>
${item.name || item.Name}
</option>
`)}
</select>
`}
</td>
<td class="row-actions">
${linked ? html`
<button class="danger" @click=${async () => { await API.unlinkPlaylist(id); await this.loadLinkData(); this.toast("Playlist unlinked"); }}>Unlink</button>
` : html`
<button class="primary" ?disabled=${!selection} @click=${async () => { await API.linkPlaylist(id, selection, "0 8 * * *"); await this.loadLinkData(); this.restartKeys = new Set([...this.restartKeys, "SPOTIFY_IMPORT_PLAYLISTS"]); this.toast("Playlist linked"); }}>Link</button>
`}
</td>
</tr>
`;
}
renderInjectedPlaylists() {
const playlists = asArray(this.playlists?.playlists || this.playlists?.Playlists);
return html`
<div class="panel">
<div class="toolbar">
<button class="primary" @click=${async () => { await this.loadPlaylists(true); this.toast("Playlists refreshed"); }}>Refresh</button>
<button @click=${async () => { await API.refreshPlaylists(); this.toast("Refresh requested"); }}>Refresh all</button>
<form class="toolbar" @submit=${this.addInjectedPlaylist}>
<div class="form-row">
<label>Name</label>
<input name="name" required>
</div>
<div class="form-row">
<label>Spotify ID</label>
<input name="spotifyId" required>
</div>
<button>Add</button>
</form>
</div>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Tracks</th><th>Local</th><th>External</th><th>Schedule</th><th></th></tr></thead>
<tbody>
${playlists.length ? playlists.map((playlist) => html`
<tr>
<td><strong>${playlist.name}</strong><div class="muted mono">${playlist.id}</div></td>
<td>${display(playlist.trackCount)}</td>
<td>${display(playlist.localTracks)}</td>
<td>${display(playlist.externalTracks)}</td>
<td><span class="mono">${display(playlist.syncSchedule)}</span></td>
<td class="row-actions">
<button @click=${async () => { await API.refreshPlaylist(playlist.name); this.toast("Playlist refresh requested"); }}>Refresh</button>
<button @click=${async () => { await API.matchPlaylist(playlist.name); this.toast("Matching requested"); }}>Match</button>
<button @click=${async () => { await API.clearPlaylistCache(playlist.name); this.toast("Cache cleared"); }}>Clear</button>
<button class="danger" @click=${async () => { await API.removePlaylist(playlist.name); await this.loadPlaylists(true); this.toast("Playlist removed"); }}>Remove</button>
</td>
</tr>
`) : html`<tr><td colspan="6"><div class="empty">No injected playlists loaded.</div></td></tr>`}
</tbody>
</table>
</div>
`;
}
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`
<div class="grid">
<div class="card metric"><span class="metric-label">Total</span><span class="metric-value">${display(stats.TotalMappings ?? stats.totalMappings ?? 0)}</span></div>
<div class="card metric"><span class="metric-label">Local</span><span class="metric-value">${display(stats.LocalMappings ?? stats.localMappings ?? 0)}</span></div>
<div class="card metric"><span class="metric-label">External</span><span class="metric-value">${display(stats.ExternalMappings ?? stats.externalMappings ?? 0)}</span></div>
<div class="card metric"><span class="metric-label">Manual</span><span class="metric-value">${display(stats.ManualMappings ?? stats.manualMappings ?? 0)}</span></div>
</div>
<div class="panel">
<div class="toolbar">
<div class="form-row">
<label>Search</label>
<input .value=${this.mappingFilters.search} @input=${(event) => { this.mappingFilters.search = event.target.value; }}>
</div>
<div class="form-row">
<label>Target</label>
<select .value=${this.mappingFilters.targetType} @change=${(event) => { this.mappingFilters.targetType = event.target.value; }}>
<option value="all">All</option>
<option value="local">Local</option>
<option value="external">External</option>
</select>
</div>
<button class="primary" @click=${async () => { this.mappingFilters.page = 1; await this.loadMappings(); }}>Apply</button>
</div>
</div>
<div class="panel">
<h3>Manual mapping</h3>
<form class="toolbar" @submit=${this.saveManualMapping}>
<div class="form-row"><label>Spotify ID</label><input name="spotifyId" required></div>
<div class="form-row">
<label>Target type</label>
<select name="targetType">
<option value="local">Local</option>
<option value="external">External</option>
</select>
</div>
<div class="form-row"><label>Local ID</label><input name="localId"></div>
<div class="form-row"><label>External provider</label><input name="externalProvider" placeholder="deezer"></div>
<div class="form-row"><label>External ID</label><input name="externalId"></div>
<button>Save</button>
</form>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Track</th><th>Spotify ID</th><th>Target</th><th>Source</th><th>Created</th><th></th></tr></thead>
<tbody>
${mappings.length ? mappings.map((mapping) => this.renderMappingRow(mapping)) : html`
<tr><td colspan="6"><div class="empty">No mappings found.</div></td></tr>
`}
</tbody>
</table>
</div>
<div class="panel">
<div class="actions">
<button ?disabled=${(pagination.page ?? 1) <= 1} @click=${async () => { this.mappingFilters.page -= 1; await this.loadMappings(); }}>Previous</button>
<span class="chip">Page ${display(pagination.page ?? 1)} of ${display(pagination.totalPages ?? 1)}</span>
<button ?disabled=${(pagination.page ?? 1) >= (pagination.totalPages ?? 1)} @click=${async () => { this.mappingFilters.page += 1; await this.loadMappings(); }}>Next</button>
</div>
</div>
`;
}
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`
<tr>
<td>
<div class="track">
<img class="art" src=${metadata.ArtworkUrl || metadata.artworkUrl || "/placeholder.png"} alt="">
<div>
<strong>${display(metadata.Title || metadata.title, "Unknown track")}</strong>
<div class="muted">${display(metadata.Artist || metadata.artist, "Unknown artist")}</div>
</div>
</div>
</td>
<td><span class="mono">${spotifyId}</span></td>
<td>${this.renderMappingTargets(mapping)}</td>
<td><span class="chip">${display(source)}</span></td>
<td>${formatDate(mapping.CreatedAt || mapping.createdAt)}</td>
<td><button class="danger" @click=${async () => { await API.deleteMapping(spotifyId); await this.loadMappings(); this.toast("Mapping deleted"); }}>Delete</button></td>
</tr>
`;
}
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`<span class="chip">${label}: <span class="mono">${value}</span></span>`) : html`<span class="muted">None</span>`}`;
}
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`
<div class="panel">
<div class="toolbar">
<button class="primary" @click=${async () => { await this.loadMigrationData(); this.toast("Missing track data refreshed"); }}>Refresh</button>
<span class="chip warning">${missingCount} unmatched</span>
</div>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Playlist</th><th>Tracks</th><th>Local</th><th>External</th><th>Missing</th></tr></thead>
<tbody>
${playlists.length ? playlists.map((playlist) => html`
<tr>
<td><strong>${playlist.name}</strong></td>
<td>${display(playlist.trackCount)}</td>
<td>${display(playlist.localTracks)}</td>
<td>${display(playlist.externalTracks)}</td>
<td><span class="chip ${Number(playlist.externalMissing || 0) > 0 ? "warning" : "success"}">${display(playlist.externalMissing || 0)}</span></td>
</tr>
`) : html`<tr><td colspan="5"><div class="empty">No playlist data loaded.</div></td></tr>`}
</tbody>
</table>
</div>
`;
}
renderSongMigration() {
const playlists = asArray(this.playlists?.playlists || this.playlists?.Playlists);
const externalTotal = playlists.reduce((sum, playlist) => sum + Number(playlist.externalTracks || 0), 0);
return html`
<div class="grid">
<div class="card metric"><span class="metric-label">Playlists</span><span class="metric-value">${playlists.length}</span></div>
<div class="card metric"><span class="metric-label">External tracks</span><span class="metric-value">${externalTotal}</span></div>
</div>
<div class="panel">
<div class="toolbar">
<button class="primary" @click=${async () => { await this.loadMigrationData(); this.toast("Migration data refreshed"); }}>Refresh</button>
<button @click=${() => this.downloadMigrationCsv()}>Export CSV</button>
</div>
</div>
${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`
<div class="grid">
<div class="card metric"><span class="metric-label">Files</span><span class="metric-value">${display(this.downloads?.count ?? this.downloads?.Count ?? files.length)}</span></div>
<div class="card metric"><span class="metric-label">Size</span><span class="metric-value">${display(this.downloads?.totalSizeFormatted ?? this.downloads?.TotalSizeFormatted)}</span></div>
</div>
<div class="panel">
<div class="actions">
<button class="primary" @click=${async () => { await this.loadDownloads(); this.toast("Downloads refreshed"); }}>Refresh</button>
<button class="danger" @click=${async () => { if (confirm("Delete all kept downloads?")) { await API.deleteAllDownloads(); await this.loadDownloads(); this.toast("Downloads deleted"); } }}>Delete all</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Artist</th><th>Album</th><th>File</th><th>Size</th><th></th></tr></thead>
<tbody>
${files.length ? files.map((file) => html`
<tr>
<td>${display(file.artist)}</td>
<td>${display(file.album)}</td>
<td class="mono">${display(file.fileName)}</td>
<td>${display(file.sizeFormatted)}</td>
<td><button class="danger" @click=${async () => { await API.deleteDownload(file.path); await this.loadDownloads(); this.toast("Download deleted"); }}>Delete</button></td>
</tr>
`) : html`<tr><td colspan="5"><div class="empty">No kept downloads found.</div></td></tr>`}
</tbody>
</table>
</div>
`;
}
renderExternalPlaylistExplorer() {
const results = asArray(this.externalPlaylists?.results || this.externalPlaylists?.Results);
return html`
<div class="panel">
<div class="toolbar">
<div class="form-row">
<label>Provider</label>
<select .value=${this.externalPlaylistProvider} @change=${(event) => { this.externalPlaylistProvider = event.target.value; }}>
<option value="deezer">Deezer</option>
<option value="qobuz">Qobuz</option>
<option value="squidwtf">SquidWTF</option>
<option value="applemusic">Apple Music</option>
</select>
</div>
<div class="form-row">
<label>Query</label>
<input .value=${this.externalPlaylistQuery} @input=${(event) => { this.externalPlaylistQuery = event.target.value; }} @keydown=${(event) => { if (event.key === "Enter") this.searchExternalPlaylists(); }}>
</div>
<button class="primary" @click=${() => this.searchExternalPlaylists()}>Search</button>
</div>
</div>
<div class="provider-grid">
${results.length ? results.map((playlist) => this.renderExternalPlaylistCard(playlist)) : html`<div class="empty">No external playlists loaded.</div>`}
</div>
`;
}
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`
<div class="card provider-card">
<div class="provider-head">
<div class="provider-title">
<strong>${playlist.name || playlist.Name}</strong>
<span>${display(playlist.curatorName || playlist.CuratorName, provider)}</span>
</div>
<span class="status-chip configured">${provider}</span>
</div>
<div class="stat-list">
<div class="stat-row"><span>Tracks</span><strong>${display(playlist.trackCount || playlist.TrackCount)}</strong></div>
<div class="stat-row"><span>ID</span><span class="mono">${display(externalId)}</span></div>
</div>
<button @click=${async () => {
const data = await API.externalPlaylistTracks(provider, externalId, 25);
this.externalPlaylistTracks = new Map([...this.externalPlaylistTracks, [key, data]]);
}}>Preview tracks</button>
${tracks ? html`
<div class="activity-list">
${asArray(tracks.results || tracks.Results).slice(0, 5).map((track) => html`
<div class="activity-item">
<strong>${track.title || track.Title}</strong>
<span class="muted">${track.artist || track.Artist}</span>
</div>
`)}
</div>
` : nothing}
</div>
`;
}
renderSources() {
return html`
<section class="view-stack">
<div class="view-header">
<div>
<h2>Services and sources</h2>
<p>Provider configuration, source priority, and extension management.</p>
</div>
</div>
<div class="provider-grid">
${asArray(this.schema?.providers).map((provider) => this.renderProviderCard(provider))}
</div>
${this.renderPriorityGroups()}
${this.renderExtensions()}
</section>
`;
}
renderProviderCard(provider) {
return html`
<div class="card provider-card">
<div class="provider-head">
<div class="provider-title">
<strong>${provider.name}</strong>
<span>${asArray(provider.categories).join(", ")}</span>
</div>
<span class="status-chip ${provider.status}">${titleCase(provider.status)}</span>
</div>
<div class="chip-list">
${asArray(provider.notes).map((note) => html`<span class="chip">${note}</span>`)}
</div>
<div class="config-grid">
${asArray(provider.configSchema).map((field) => this.renderConfigField(field))}
</div>
</div>
`;
}
renderPriorityGroups() {
return html`
<div class="panel">
<h3>Provider priority</h3>
<div class="grid">
${asArray(this.schema?.priorityGroups).map((group) => html`
<div class="card">
<h3>${group.label}</h3>
<div class="priority-list">
${asArray(group.providers).map((provider, index) => html`
<span class="priority-item">
<span>${provider}</span>
<button ?disabled=${index === 0} @click=${() => this.movePriority(group, index, -1)}>Up</button>
<button ?disabled=${index === group.providers.length - 1} @click=${() => this.movePriority(group, index, 1)}>Down</button>
</span>
`)}
</div>
${group.enabledEnvKey ? html`
<div class="config-grid" style="margin-top: var(--space-4);">
${this.renderConfigField({
key: group.enabledEnvKey,
label: "Enabled providers",
type: "text",
valuePath: group.id === "metadata" ? "providers.enabledSearch" : "providers.enabledPlaylist",
requiresRestart: true,
})}
</div>
` : nothing}
</div>
`)}
</div>
</div>
`;
}
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`
<div class="panel">
<div class="view-header">
<div>
<h3>Extension store</h3>
<p>Installed extensions are loaded into the metadata/search sandbox.</p>
</div>
<div class="actions">
<button @click=${async () => { await this.loadInstalledExtensions(); this.toast("Installed extensions refreshed"); }}>Installed</button>
<button class="primary" @click=${async () => { await this.loadExtensionStore(); this.toast("Store loaded"); }}>Load store</button>
</div>
</div>
<div class="config-grid">${this.renderConfigField(repoField)}</div>
</div>
${errors.length ? html`<div class="panel">${errors.map((error) => html`<div class="error-text">${error.Repository || error.repository}: ${error.Message || error.message}</div>`)}</div>` : nothing}
<div class="grid">
<div class="panel">
<h3>Installed extensions</h3>
<div class="activity-list">
${installed.length ? installed.map((item) => html`
<div class="activity-item">
<strong>${item.displayName || item.DisplayName || item.name || item.Name}</strong>
<span class="muted">${display(item.description || item.Description)}</span>
<div class="row-actions">
${asArray(item.types || item.Types).map((type) => html`<span class="chip">${type}</span>`)}
<button class="danger" @click=${async () => { await API.uninstallExtension(item.id || item.Id); await this.loadInstalledExtensions(); this.toast("Extension uninstalled"); }}>Uninstall</button>
</div>
</div>
`) : html`<div class="empty">No extensions installed.</div>`}
</div>
</div>
<div class="panel">
<h3>Store</h3>
<div class="activity-list">
${storeItems.length ? storeItems.map((item) => html`
<div class="activity-item">
<strong>${item.displayName || item.DisplayName}</strong>
<span class="muted">${display(item.description || item.Description)}</span>
<div class="row-actions">
<span class="chip">${display(item.version || item.Version)}</span>
<button class="primary" ?disabled=${Boolean(item.isInstalled || item.IsInstalled)} @click=${async () => { await API.installExtension(item); await this.loadInstalledExtensions(); await this.loadExtensionStore(); this.toast("Extension installed"); }}>
${item.isInstalled || item.IsInstalled ? "Installed" : "Install"}
</button>
</div>
</div>
`) : html`<div class="empty">Load the store to browse extensions.</div>`}
</div>
</div>
</div>
`;
}
renderActivity() {
return html`
<section class="view-stack">
<div class="view-header">
<div>
<h2>Activity</h2>
<p>Download queue, scrobbling, and endpoint usage.</p>
</div>
<button class="primary" @click=${async () => { await Promise.all([this.loadEndpointUsage(), this.loadScrobbling(), this.loadQueue()]); this.toast("Activity refreshed"); }}>Refresh</button>
</div>
<div class="wide-grid">
<div class="panel">
<h3>Download queue</h3>
${this.renderActivityList(this.activity)}
</div>
<div class="panel">
<h3>Scrobbling</h3>
${this.renderScrobbling()}
</div>
</div>
<div class="panel">
<div class="view-header">
<div><h3>API analytics</h3></div>
<button class="danger" @click=${async () => { await API.clearEndpointUsage(); await this.loadEndpointUsage(); this.toast("Endpoint usage cleared"); }}>Clear</button>
</div>
${this.renderEndpointUsage()}
</div>
</section>
`;
}
renderActivityList(items) {
const entries = asArray(items);
if (!entries.length) {
return html`<div class="empty">No active download or playback activity.</div>`;
}
return html`
<div class="activity-list">
${entries.map((entry) => html`
<div class="activity-item">
<div class="stat-row">
<div>
<strong>${display(entry.title || entry.Title, "Unknown track")}</strong>
<div class="muted">${display(entry.artist || entry.Artist)} ${entry.externalProvider || entry.ExternalProvider ? `- ${entry.externalProvider || entry.ExternalProvider}` : ""}</div>
</div>
<span class="status-chip ${entry.isPlaying || entry.IsPlaying ? "configured" : ""}">${display(entry.status || entry.Status)}</span>
</div>
<div class="progress" style=${`--progress:${percent(entry.playbackProgress ?? entry.PlaybackProgress ?? entry.progress ?? entry.Progress)}%`}>
<span></span>
</div>
</div>
`)}
</div>
`;
}
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`
<div class="stat-list">
<div class="stat-row"><span>Runtime</span><span class="status-chip ${status.enabled || status.Enabled ? "configured" : "needs_config"}">${status.enabled || status.Enabled ? "Enabled" : "Disabled"}</span></div>
</div>
<div class="config-grid">${fields.map((field) => this.renderConfigField(field))}</div>
<div class="actions">
<button @click=${async () => { await API.testLastFm(); this.toast("Last.fm test completed"); }}>Test Last.fm</button>
<button @click=${async () => { await API.testListenBrainz(); this.toast("ListenBrainz test completed"); }}>Test ListenBrainz</button>
</div>
`;
}
renderEndpointUsage() {
const endpoints = asArray(this.endpointUsage?.endpoints || this.endpointUsage?.Endpoints);
return html`
<div class="grid">
<div class="card metric"><span class="metric-label">Requests</span><span class="metric-value">${display(this.endpointUsage?.totalRequests || this.endpointUsage?.TotalRequests || 0)}</span></div>
<div class="card metric"><span class="metric-label">Endpoints</span><span class="metric-value">${display(this.endpointUsage?.totalEndpoints || this.endpointUsage?.TotalEndpoints || endpoints.length)}</span></div>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Endpoint</th><th>Count</th></tr></thead>
<tbody>
${endpoints.length ? endpoints.map((item) => html`<tr><td class="mono">${item.endpoint || item.Endpoint}</td><td>${item.count || item.Count}</td></tr>`) : html`<tr><td colspan="2"><div class="empty">No endpoint usage data.</div></td></tr>`}
</tbody>
</table>
</div>
`;
}
renderSettings() {
return html`
<section class="view-stack">
<div class="view-header">
<div>
<h2>Settings</h2>
<p>Configuration changes are saved on blur and marked when a restart is needed.</p>
</div>
</div>
${asArray(this.schema?.configSections).map((section) => html`
<div class="panel">
<h3>${section.label}</h3>
<div class="config-grid">
${asArray(section.fields).map((field) => this.renderConfigField(field))}
</div>
</div>
`)}
<div class="panel">
<h3>Backup and restore</h3>
<div class="actions">
<button @click=${() => this.exportEnv()}>Export .env</button>
<input class="hidden" id="env-import-input" type="file" accept=".env,text/plain" @change=${(event) => this.importEnv(event)}>
<button @click=${() => this.querySelector("#env-import-input")?.click()}>Import .env</button>
</div>
</div>
<div class="panel">
<h3>Danger zone</h3>
<div class="actions">
<button class="danger" @click=${async () => { if (confirm("Clear cache?")) { await API.clearCache(); this.toast("Cache clear requested"); } }}>Clear cache</button>
<button class="danger" @click=${async () => { if (confirm("Restart Allstarr?")) { await API.restart(); this.toast("Restart requested"); } }}>Restart</button>
</div>
</div>
</section>
`;
}
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`
<div class="config-field">
<div class="field-heading">
<label class="field-label" for=${field.key}>${field.label}</label>
${saved ? html`<span class="restart-mark">Restart needed</span>` : nothing}
</div>
${field.type === "select" ? html`
<select id=${field.key} .value=${String(value)} @change=${onCommit}>
${asArray(field.options).map((option) => html`<option value=${option}>${option}</option>`)}
</select>
` : field.type === "toggle" ? html`
<label class="inline-check">
<input id=${field.key} type="checkbox" .checked=${parseBoolValue(rawValue)} @change=${onCommit}>
<span>${parseBoolValue(rawValue) ? "Enabled" : "Disabled"}</span>
</label>
` : html`
<input
id=${field.key}
type=${field.type === "password" ? "password" : field.type === "number" ? "number" : field.type === "url" ? "url" : "text"}
.value=${String(value)}
min=${field.min ?? nothing}
max=${field.max ?? nothing}
placeholder=${field.sensitive ? display(rawValue, "Set value") : display(field.placeholder, "")}
@blur=${onCommit}>
`}
</div>
`;
}
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`
<div class="restart-bar">
<span>${this.restartKeys.size} saved change${this.restartKeys.size === 1 ? "" : "s"} need restart</span>
<button class="primary" @click=${async () => { await API.restart(); this.restartKeys = new Set(); this.toast("Restart requested"); }}>Restart</button>
<button class="ghost" @click=${() => { this.restartKeys = new Set(); }}>Dismiss</button>
</div>
`;
}
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`
<footer class="now-playing">
<div>
<div class="now-title">${current ? display(current.title || current.Title, "Active download") : "No active playback"}</div>
<div class="now-meta">${current ? display(current.artist || current.Artist) : "Queue is idle"}</div>
</div>
<div class="progress" style=${`--progress:${progress}%`}><span></span></div>
</footer>
`;
}
renderToasts() {
return html`
<div class="toast-stack">
${this.toasts.map((toast) => html`<div class="toast ${toast.type}">${toast.message}</div>`)}
</div>
`;
}
}
customElements.define("allstarr-app", AllstarrApp);
+11 -762
View File
@@ -1,768 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spotify Track Mappings - Allstarr</title>
<link rel="stylesheet" href="styles.css" />
<style>
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--text-primary: #f0f6fc;
--text-secondary: #8b949e;
--accent: #58a6ff;
--accent-hover: #79c0ff;
--success: #3fb950;
--warning: #d29922;
--error: #f85149;
--border: #30363d;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="0; url=/#/library/mappings">
<title>Allstarr Mappings</title>
</head>
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
min-height: 100vh;
}
<body>
<script>
window.location.replace("/#/library/mappings");
</script>
</body>
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.support-footer {
max-width: 1400px;
margin: 0 auto 24px;
padding: 20px;
border-top: 1px solid var(--border);
color: var(--text-secondary);
font-size: 0.92rem;
text-align: center;
}
.support-footer a {
color: var(--accent);
text-decoration: none;
}
.support-footer a:hover {
color: var(--accent-hover);
text-decoration: underline;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
border-bottom: 1px solid var(--border);
margin-bottom: 30px;
}
h1 {
font-size: 1.8rem;
font-weight: 600;
}
.back-link {
color: var(--accent);
text-decoration: none;
display: flex;
align-items: center;
gap: 6px;
}
.back-link:hover {
color: var(--accent-hover);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 30px;
}
.stat-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
}
.stat-label {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 8px;
}
.stat-value {
font-size: 2rem;
font-weight: 600;
color: var(--accent);
}
.mappings-table {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.table-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.table-controls {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 12px;
}
.table-header h2 {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 12px;
}
.filters {
display: flex;
gap: 12px;
align-items: center;
}
.filter-select {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
color: var(--text-primary);
cursor: pointer;
}
.filter-select:focus {
outline: none;
border-color: var(--accent);
}
.search-box {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
color: var(--text-primary);
flex: 1;
max-width: 400px;
}
.search-box:focus {
outline: none;
border-color: var(--accent);
}
.action-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 6px 10px;
border-radius: 999px;
cursor: pointer;
font-size: 0.78rem;
transition: all 0.2s;
}
.action-btn:hover {
transform: translateY(-1px);
}
.action-btn.local {
background: rgba(63, 185, 80, 0.18);
border-color: rgba(63, 185, 80, 0.5);
color: #78d487;
}
.action-btn.external {
background: rgba(210, 153, 34, 0.18);
border-color: rgba(210, 153, 34, 0.5);
color: #e8ba5d;
}
.action-btn.danger {
background: rgba(248, 81, 73, 0.16);
border-color: rgba(248, 81, 73, 0.45);
color: #f57f78;
}
.action-btn.danger:hover {
border-color: var(--error);
color: var(--error);
}
.actions-cell {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
table {
width: 100%;
border-collapse: collapse;
}
thead {
background: var(--bg-tertiary);
}
th {
text-align: left;
padding: 12px 20px;
font-weight: 600;
font-size: 0.9rem;
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
}
th.sortable {
cursor: pointer;
user-select: none;
transition: color 0.2s;
}
th.sortable:hover {
color: var(--accent);
}
td {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
tr:last-child td {
border-bottom: none;
}
tbody tr:hover {
background: var(--bg-tertiary);
}
.track-info {
display: flex;
align-items: center;
gap: 12px;
}
.track-artwork {
width: 48px;
height: 48px;
border-radius: 4px;
object-fit: cover;
background: var(--bg-tertiary);
}
.track-details {
flex: 1;
}
.track-title {
font-weight: 500;
margin-bottom: 4px;
}
.track-artist {
color: var(--text-secondary);
font-size: 0.9rem;
}
.badge {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 500;
}
.badge.local {
background: rgba(63, 185, 80, 0.2);
color: var(--success);
}
.badge.external {
background: rgba(88, 166, 255, 0.2);
color: var(--accent);
}
.badge.manual {
background: rgba(210, 153, 34, 0.2);
color: var(--warning);
}
.badge.auto {
background: rgba(139, 148, 158, 0.2);
color: var(--text-secondary);
}
.mono {
font-family:
"SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
monospace;
font-size: 0.85rem;
color: var(--text-secondary);
}
.target-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.target-item {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.target-item .badge {
min-width: 72px;
text-align: center;
text-transform: lowercase;
}
.target-source {
font-size: 0.75rem;
color: var(--text-secondary);
}
.target-empty {
color: var(--text-secondary);
}
.target-remove-btn {
margin-left: auto;
min-width: 28px;
padding: 2px 8px;
font-size: 1rem;
line-height: 1;
border-radius: 6px;
background: rgba(248, 81, 73, 0.12);
border: 1px solid rgba(248, 81, 73, 0.45);
color: var(--error);
cursor: pointer;
}
.target-remove-btn:hover {
background: rgba(248, 81, 73, 0.25);
}
.external-existing-panel {
margin-bottom: 16px;
padding: 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: rgba(13, 17, 23, 0.35);
}
.external-existing-panel h4 {
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 10px;
font-weight: 600;
}
.external-existing-empty {
color: var(--text-secondary);
font-size: 0.85rem;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
padding: 20px;
}
.pagination button {
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
}
.pagination button:hover:not(:disabled) {
background: var(--bg-secondary);
border-color: var(--accent);
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination .page-info {
color: var(--text-secondary);
font-size: 0.9rem;
}
.loading {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
.error {
background: rgba(248, 81, 73, 0.1);
border: 1px solid var(--error);
border-radius: 8px;
padding: 16px;
margin: 20px 0;
color: var(--error);
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state h3 {
font-size: 1.2rem;
margin-bottom: 8px;
color: var(--text-primary);
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.62);
display: none;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 1000;
}
.modal-overlay.active {
display: flex;
}
.modal-card {
width: min(760px, 100%);
max-height: 90vh;
overflow: auto;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px;
}
.modal-card h3 {
margin-bottom: 10px;
}
.modal-track-info {
margin-bottom: 14px;
padding: 10px 12px;
border-radius: 8px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
}
.modal-row {
margin-bottom: 12px;
}
.modal-row label {
display: block;
margin-bottom: 6px;
color: var(--text-secondary);
font-size: 0.86rem;
}
.modal-row input,
.modal-row select {
width: 100%;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-primary);
}
.modal-actions {
margin-top: 14px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.modal-actions .primary {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.modal-actions .primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.local-results {
max-height: 280px;
overflow: auto;
border: 1px solid var(--border);
border-radius: 8px;
background: rgba(13, 17, 23, 0.36);
}
.local-result {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.local-result:last-child {
border-bottom: none;
}
.local-result:hover {
background: var(--bg-tertiary);
}
.local-result.selected {
border-left: 2px solid var(--accent);
background: rgba(88, 166, 255, 0.12);
}
.local-result .meta {
color: var(--text-secondary);
font-size: 0.82rem;
}
.toast {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 1200;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg-secondary);
}
.toast.success {
border-color: rgba(63, 185, 80, 0.6);
color: var(--success);
}
.toast.error {
border-color: rgba(248, 81, 73, 0.6);
color: var(--error);
}
</style>
<script src="/spotify-mappings.js" defer></script>
</head>
<body>
<div class="container">
<header>
<h1>Spotify Track Mappings</h1>
<a href="/" class="back-link"> ← Back to Dashboard </a>
</header>
<div class="stats-grid" id="stats">
<div class="stat-card">
<div class="stat-label">Total Mappings</div>
<div class="stat-value" id="stat-total">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Local Tracks</div>
<div class="stat-value" id="stat-local">-</div>
</div>
<div class="stat-card">
<div class="stat-label">External Tracks</div>
<div class="stat-value" id="stat-external">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Manual Mappings</div>
<div class="stat-value" id="stat-manual">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Auto Mappings</div>
<div class="stat-value" id="stat-auto">-</div>
</div>
</div>
<div class="mappings-table">
<div class="table-header">
<h2>All Mappings</h2>
<div class="table-controls">
<div class="filters">
<select class="filter-select" id="filter-type">
<option value="all">All Types</option>
<option value="local">Local Only</option>
<option value="external">External Only</option>
</select>
<select class="filter-select" id="filter-source">
<option value="all">All Sources</option>
<option value="manual">Manual Only</option>
<option value="auto">Auto Only</option>
</select>
</div>
<input
type="text"
class="search-box"
placeholder="Search by title, artist, or Spotify ID..."
id="search"
/>
</div>
</div>
<div id="content">
<div class="loading">Loading mappings...</div>
</div>
<div class="pagination" id="pagination" style="display: none">
<button id="prev-btn">← Previous</button>
<span class="page-info" id="page-info">Page 1 of 1</span>
<button id="next-btn">Next →</button>
</div>
</div>
</div>
<div class="modal-overlay" id="local-map-modal">
<div class="modal-card">
<h3>Map Spotify Track to Local Jellyfin Track</h3>
<div class="modal-track-info">
<strong id="local-map-title"></strong><br />
<span
style="color: var(--text-secondary)"
id="local-map-artist"
></span
><br />
<span class="mono" id="local-map-spotify-id"></span>
</div>
<div class="modal-row">
<label for="local-map-search"
>Search Jellyfin Library</label
>
<div style="display: flex; gap: 8px">
<input
id="local-map-search"
type="text"
placeholder="Track title or artist"
/>
<button id="local-map-search-btn" class="action-btn">
Search
</button>
</div>
</div>
<div class="local-results" id="local-map-results">
<div class="loading" style="padding: 16px">
Search to find matching local tracks.
</div>
</div>
<div class="modal-actions">
<button id="local-map-cancel-btn">Cancel</button>
<button id="local-map-save-btn" class="primary" disabled>
Save Mapping
</button>
</div>
</div>
</div>
<div class="modal-overlay" id="external-map-modal">
<div class="modal-card">
<h3>Map Spotify Track to External Provider</h3>
<div class="modal-track-info">
<strong id="external-map-title"></strong><br />
<span
style="color: var(--text-secondary)"
id="external-map-artist"
></span
><br />
<span class="mono" id="external-map-spotify-id"></span>
</div>
<div class="external-existing-panel" id="external-map-existing-panel">
<h4>Existing external mappings</h4>
<div id="external-map-existing"></div>
</div>
<div class="modal-row">
<label for="external-map-provider">Add or update provider</label>
<select id="external-map-provider">
<option value="squidwtf">SquidWTF</option>
<option value="deezer">Deezer</option>
<option value="qobuz">Qobuz</option>
</select>
</div>
<div class="modal-row">
<label for="external-map-id">External ID</label>
<input
id="external-map-id"
type="text"
placeholder="Provider track ID or URL"
/>
</div>
<div class="modal-actions">
<button id="external-map-cancel-btn">Cancel</button>
<button id="external-map-save-btn" class="primary" disabled>
Save Mapping
</button>
</div>
</div>
</div>
<footer class="support-footer">
<p class="support-text">
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
supporting its development
</p>
<ul class="support-funding-icons">
<li>
<a class="support-funding-link" href="https://ko-fi.com/joshpatra" target="_blank"
rel="noopener noreferrer" aria-label="Support on Ko-fi">
<img src="images/kofi_symbol.svg" alt="" width="37" height="30" decoding="async" />
</a>
</li>
<li>
<a class="support-funding-link" href="https://github.com/sponsors/SoPat712" target="_blank"
rel="noopener noreferrer" aria-label="GitHub Sponsors">
<img src="images/github-mark.svg" alt="" width="30" height="29" decoding="async" />
</a>
</li>
<li>
<a class="support-funding-link" href="https://buymeacoffee.com/treeman183" target="_blank"
rel="noopener noreferrer" aria-label="Buy Me a Coffee">
<img src="images/buymeacoffee-symbol.svg" alt="" width="30" height="30" decoding="async" />
</a>
</li>
</ul>
</footer>
</body>
</html>