mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
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:
16 files changed
+3562
-2651
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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; } = "";
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;">← 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 →</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>
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user