feat(webui): refine provider integration controls

This commit is contained in:
joshpatra committed 2026-07-08 23:41:12 -04:00
1 parent 25f7b063cb
commit b204af8602
5 files changed
+645 -59

No files matched your search

+3 -2
View File
@@ -165,7 +165,7 @@ public class AdminUiController : ControllerBase
Id = "applemusic",
Name = "Apple Music",
Icon = "applemusic",
Status = string.IsNullOrWhiteSpace(_appleMusicSettings.BaseUrl) ? "needs_config" : "configured",
Status = string.IsNullOrWhiteSpace(_appleMusicSettings.BaseUrl) ? "needs_config" : "needs_login",
Categories = ["metadata", "download", "playlist"],
ConfigSchema =
[
@@ -219,10 +219,11 @@ public class AdminUiController : ControllerBase
new()
{
Id = "musicbrainz",
Name = "MusicBrainz",
Name = "MusicBrainz enrichment",
Icon = "musicbrainz",
Status = _musicBrainzSettings.Enabled ? "configured" : "disabled",
Categories = ["metadata"],
Notes = ["Genres only", "Optional enrichment"],
ConfigSchema =
[
Field("MUSICBRAINZ_ENABLED", "Enabled", "toggle", "musicBrainz.enabled"),
+61 -5
View File
@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Nodes;
using allstarr.Models.Settings;
using allstarr.Filters;
@@ -33,14 +35,44 @@ public class AppleMusicController : ControllerBase
{
try
{
var res = await _httpClient.GetAsync("api/health");
var json = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode)
var healthResponse = await _httpClient.GetAsync("api/health");
var healthJson = await healthResponse.Content.ReadAsStringAsync();
if (!healthResponse.IsSuccessStatusCode)
{
return StatusCode((int)res.StatusCode, json);
return StatusCode((int)healthResponse.StatusCode, healthJson);
}
return Content(json, "application/json");
var status = TryParseObject(healthJson) ?? new JsonObject();
var daemonRunning = GetBoolean(status, "daemon_running") == true;
if (daemonRunning)
{
try
{
var meResponse = await _httpClient.GetAsync("api/me");
var meJson = await meResponse.Content.ReadAsStringAsync();
if (TryParseObject(meJson) is { } me)
{
status["account"] = me;
var state = me["state"]?.GetValue<string>();
if (!string.IsNullOrWhiteSpace(state))
{
status["login_state"] = state;
status["logged_in"] = string.Equals(state, "authenticated", StringComparison.OrdinalIgnoreCase);
}
else if (GetBoolean(me, "logged_in") is { } loggedIn)
{
status["logged_in"] = loggedIn;
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Apple Music /api/me status probe failed");
status["account_error"] = "Unable to read wrapper account state.";
}
}
return Content(status.ToJsonString(), "application/json");
}
catch (Exception ex)
{
@@ -132,4 +164,28 @@ public class AppleMusicController : ControllerBase
return StatusCode(500, new { error = ex.Message });
}
}
private static JsonObject? TryParseObject(string json)
{
try
{
return JsonNode.Parse(json) as JsonObject;
}
catch (JsonException)
{
return null;
}
}
private static bool? GetBoolean(JsonObject source, string key)
{
try
{
return source[key]?.GetValue<bool>();
}
catch (InvalidOperationException)
{
return null;
}
}
}
@@ -109,7 +109,7 @@ public class DownloadActivityController : ControllerBase
group => group.Key,
group => group.OrderByDescending(state => state.LastActivity).First());
return orderedDownloads
var entries = orderedDownloads
.Select(download =>
{
var normalizedSongId = NormalizeExternalItemId(download.SongId);
@@ -144,6 +144,40 @@ public class DownloadActivityController : ControllerBase
};
})
.ToList();
var knownIds = orderedDownloads
.Select(download => NormalizeExternalItemId(download.SongId))
.Where(id => !string.IsNullOrWhiteSpace(id))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var (itemId, playbackState) in playbackByItemId)
{
if (string.IsNullOrWhiteSpace(itemId) || knownIds.Contains(itemId))
{
continue;
}
entries.Add(new DownloadActivityEntry
{
SongId = itemId,
ExternalId = itemId,
ExternalProvider = ResolvePlaybackProvider(itemId),
Title = ResolvePlaybackTitle(itemId),
Artist = playbackState.DeviceId,
Status = DownloadStatus.Completed,
Progress = 1,
RequestedForStreaming = false,
StartedAt = playbackState.LastActivity,
IsPlaying = true,
PlaybackPositionSeconds = (int)Math.Max(0, playbackState.PositionTicks / TimeSpan.TicksPerSecond)
});
}
return entries
.OrderByDescending(entry => entry.IsPlaying)
.ThenByDescending(entry => entry.Status == DownloadStatus.InProgress)
.ThenByDescending(entry => entry.StartedAt)
.ToList();
}
private static string NormalizeExternalItemId(string itemId)
@@ -174,6 +208,30 @@ public class DownloadActivityController : ControllerBase
return $"ext-{parts[1]}-song-{string.Join("-", parts.Skip(2))}";
}
private static string ResolvePlaybackProvider(string itemId)
{
if (!itemId.StartsWith("ext-", StringComparison.OrdinalIgnoreCase))
{
return "jellyfin";
}
var parts = itemId.Split('-', StringSplitOptions.RemoveEmptyEntries);
return parts.Length > 1 ? parts[1].ToLowerInvariant() : "external";
}
private static string ResolvePlaybackTitle(string itemId)
{
if (!itemId.StartsWith("ext-", StringComparison.OrdinalIgnoreCase))
{
return "Local Jellyfin track";
}
var parts = itemId.Split('-', StringSplitOptions.RemoveEmptyEntries);
return parts.Length > 3
? string.Join("-", parts.Skip(3))
: "External track";
}
private sealed class DownloadActivityEntry : DownloadInfo
{
public bool IsPlaying { get; init; }
+163 -8
View File
@@ -225,14 +225,14 @@ a {
}
.topbar {
position: sticky;
position: static;
top: 0;
z-index: 4;
display: flex;
display: none;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
min-height: 72px;
min-height: 56px;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--surface-page), transparent 8%);
backdrop-filter: blur(14px);
@@ -361,17 +361,22 @@ a {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 26px;
min-height: 22px;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--text-secondary);
background: var(--surface-elevated);
padding: 3px 9px;
font-size: 0.78rem;
padding: 2px 7px;
font-size: 0.72rem;
font-weight: 700;
white-space: nowrap;
}
button.chip {
min-height: 24px;
padding: 2px 8px;
}
.status-chip.configured,
.status-chip.healthy,
.chip.success {
@@ -381,6 +386,7 @@ a {
}
.status-chip.needs_config,
.status-chip.needs_login,
.status-chip.warning,
.chip.warning {
color: var(--warning);
@@ -395,6 +401,10 @@ a {
border-color: color-mix(in srgb, var(--error), transparent 58%);
}
.chip.muted-chip {
opacity: 0.72;
}
.subnav {
flex-wrap: wrap;
}
@@ -482,21 +492,84 @@ tr:last-child td {
display: grid;
gap: var(--space-4);
min-height: 180px;
min-width: 0;
overflow: hidden;
}
.provider-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-3);
min-width: 0;
}
.provider-brand {
display: flex;
align-items: center;
gap: var(--space-3);
min-width: 0;
}
.provider-logo {
display: inline-grid;
place-items: center;
flex: 0 0 auto;
min-width: 72px;
height: 32px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface-elevated);
color: var(--text-primary);
font-size: 0.72rem;
font-weight: 900;
padding: 0 8px;
}
.provider-spotify {
color: #062b15;
background: #1ed760;
border-color: #1ed760;
}
.provider-applemusic {
color: #fff;
background: linear-gradient(135deg, #fa233b, #fb5c74);
border-color: #fb5c74;
}
.provider-deezer {
color: #fff;
background: linear-gradient(135deg, #ff0092, #ffed00 55%, #00c7f2);
border-color: transparent;
}
.provider-qobuz {
color: #fff;
background: #141414;
}
.provider-squidwtf {
color: #fff;
background: #2b6f6a;
}
.provider-musicbrainz {
color: #fff;
background: #ba478f;
}
.provider-title {
display: grid;
gap: 2px;
min-width: 0;
}
.provider-title strong {
font-size: 1rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-title span {
@@ -506,13 +579,15 @@ tr:last-child td {
.config-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--space-4);
min-width: 0;
}
.config-field {
display: grid;
gap: var(--space-2);
min-width: 0;
}
.field-heading {
@@ -580,6 +655,21 @@ tr:last-child td {
background: var(--accent);
}
.progress.indeterminate span {
width: 42%;
min-width: 64px;
animation: indeterminate-progress 1.1s ease-in-out infinite;
}
@keyframes indeterminate-progress {
0% {
transform: translateX(-110%);
}
100% {
transform: translateX(250%);
}
}
.now-playing {
position: fixed;
right: var(--space-5);
@@ -599,6 +689,13 @@ tr:last-child td {
backdrop-filter: blur(16px);
}
.now-track {
display: flex;
align-items: center;
gap: var(--space-3);
min-width: 0;
}
.now-title {
font-weight: 800;
overflow: hidden;
@@ -632,13 +729,66 @@ tr:last-child td {
.toast-stack {
position: fixed;
right: var(--space-5);
top: 86px;
top: var(--space-5);
z-index: 10;
display: grid;
gap: var(--space-2);
width: min(420px, calc(100vw - 32px));
}
.inline-panel {
display: grid;
gap: var(--space-4);
min-width: 0;
}
.compact {
gap: var(--space-2);
}
.compact .stat-row {
padding-bottom: var(--space-2);
}
.compact-form {
gap: var(--space-3);
}
.capability-list,
.provider-enabled-list {
flex-wrap: wrap;
min-width: 0;
}
.scrobble-actions {
margin-top: var(--space-4);
}
.callout {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface-elevated);
color: var(--text-secondary);
padding: var(--space-3);
font-size: 0.86rem;
}
.callout.success {
border-color: color-mix(in srgb, var(--success), transparent 55%);
color: var(--success);
}
.callout.error {
border-color: color-mix(in srgb, var(--error), transparent 50%);
color: var(--error);
}
.callout.warning,
.callout.running {
border-color: color-mix(in srgb, var(--warning), transparent 55%);
color: var(--warning);
}
.toast {
border: 1px solid var(--border);
border-radius: var(--radius);
@@ -689,6 +839,11 @@ tr:last-child td {
display: inline-flex;
}
.topbar {
position: sticky;
display: flex;
}
.content,
.topbar {
padding-left: var(--space-4);
+359 -43
View File
@@ -89,12 +89,56 @@ function parseBoolValue(value) {
return false;
}
function splitCsv(value) {
return String(value || "")
.split(",")
.map((item) => item.trim().toLowerCase())
.filter(Boolean);
}
function joinCsv(values) {
return [...new Set(asArray(values).map((item) => String(item).trim().toLowerCase()).filter(Boolean))].join(",");
}
function normalizedFieldValue(field, value) {
if (field.type === "toggle") {
return parseBoolValue(value) ? "true" : "false";
}
return String(value ?? "");
}
function providerMark(provider) {
const id = String(provider?.id || provider?.Id || provider?.name || provider?.Name || "").toLowerCase();
const marks = {
spotify: "Spotify",
applemusic: "Apple Music",
deezer: "Deezer",
qobuz: "Qobuz",
squidwtf: "SquidWTF",
musicbrainz: "MusicBrainz",
lyricsplus: "Lyrics+",
lrclib: "LRCLib",
extensions: "Extensions",
};
return marks[id] || titleCase(provider?.name || provider?.Name || id);
}
function isAwaitingApple2fa(value) {
const state = String(value?.state || value?.login_state || value?.account?.state || "").toLowerCase();
return state === "awaiting_2fa";
}
async function readErrorMessage(response, fallback) {
try {
const data = await response.json();
return data.error || data.message || fallback;
const data = await response.clone().json();
return data.detail || data.error || data.message || fallback;
} catch {
return fallback;
try {
const text = await response.text();
return text || fallback;
} catch {
return fallback;
}
}
}
@@ -219,6 +263,38 @@ const API = {
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"),
appleMusicStatus: () => requestJson("/api/admin/applemusic/status", { cache: "no-store" }, "Failed to load Apple Music status"),
appleMusicLogin: (username, password) =>
requestJson("/api/admin/applemusic/login", jsonBody({ username, password }), "Failed to start Apple Music login"),
appleMusic2fa: (code) =>
requestJson("/api/admin/applemusic/login/2fa", jsonBody({ code }), "Failed to submit Apple Music 2FA"),
appleMusicSetup: (file, onProgress) => new Promise((resolve, reject) => {
const data = new FormData();
data.append("file", file);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/admin/applemusic/setup");
xhr.withCredentials = true;
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && typeof onProgress === "function") {
onProgress(Math.round((event.loaded / event.total) * 100));
}
};
xhr.onload = () => {
let payload = {};
try {
payload = xhr.responseText ? JSON.parse(xhr.responseText) : {};
} catch {
payload = { error: xhr.responseText };
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve(payload);
} else {
reject(new Error(payload.detail || payload.error || payload.message || "Apple Music setup failed"));
}
};
xhr.onerror = () => reject(new Error("Apple Music setup upload failed"));
xhr.send(data);
}),
};
class ThemeManager {
@@ -263,6 +339,10 @@ class AllstarrApp extends LitElement {
extensionStore: { state: true },
installedExtensions: { state: true },
scrobbling: { state: true },
appleMusicStatus: { state: true },
appleMusicUploadProgress: { state: true },
serviceResults: { state: true },
extensionActions: { state: true },
};
constructor() {
@@ -292,6 +372,10 @@ class AllstarrApp extends LitElement {
this.extensionStore = null;
this.installedExtensions = null;
this.scrobbling = null;
this.appleMusicStatus = null;
this.appleMusicUploadProgress = 0;
this.serviceResults = {};
this.extensionActions = {};
this.linkSelections = new Map();
this.mappingFilters = { page: 1, pageSize: 50, enrichMetadata: true, targetType: "all", source: "all", search: "" };
this.externalPlaylistProvider = "deezer";
@@ -374,7 +458,7 @@ class AllstarrApp extends LitElement {
const [zone, sub] = routeParts(this.route);
try {
if (zone === "library") {
if (sub === "link") {
if (!sub || sub === "link") {
await this.loadLinkData();
} else if (sub === "injected") {
await this.loadPlaylists();
@@ -386,7 +470,12 @@ class AllstarrApp extends LitElement {
await this.loadDownloads();
}
} else if (zone === "sources") {
await this.loadInstalledExtensions();
await Promise.all([
this.loadInstalledExtensions(),
this.loadAppleMusicStatus().catch((error) => {
this.appleMusicStatus = { error: error.message, logged_in: false };
}),
]);
} else if (zone === "activity") {
await Promise.all([this.loadEndpointUsage(), this.loadScrobbling(), this.loadQueue()]);
}
@@ -462,6 +551,11 @@ class AllstarrApp extends LitElement {
return;
}
const currentValue = getPathValue(this.config, field.valuePath, "");
if (!field.sensitive && normalizedFieldValue(field, currentValue) === normalizedFieldValue(field, value)) {
return;
}
await API.updateConfig(field.key, value);
if (field.valuePath) {
const nextConfig = structuredClone(this.config || {});
@@ -540,6 +634,160 @@ class AllstarrApp extends LitElement {
this.scrobbling = await API.scrobblingStatus();
}
async loadAppleMusicStatus() {
this.appleMusicStatus = await API.appleMusicStatus();
}
async installExtension(item) {
const key = item.id || item.Id || item.displayName || item.DisplayName;
this.extensionActions = { ...this.extensionActions, [key]: "Installing" };
try {
await API.installExtension(item);
await Promise.all([this.loadInstalledExtensions(), this.loadExtensionStore()]);
this.toast("Extension installed");
} finally {
const nextActions = { ...this.extensionActions };
delete nextActions[key];
this.extensionActions = nextActions;
}
}
async runServiceAction(key, action) {
this.serviceResults = { ...this.serviceResults, [key]: { state: "running", message: "Testing..." } };
try {
const result = await action();
this.serviceResults = {
...this.serviceResults,
[key]: { state: "success", message: result.message || result.Message || "Connection test completed." },
};
} catch (error) {
this.serviceResults = {
...this.serviceResults,
[key]: { state: "error", message: error.message },
};
}
}
providerGroup(category) {
return asArray(this.schema?.priorityGroups).find((group) => group.id === category);
}
capabilityConfig(category) {
if (category === "metadata") {
return { envKey: "MULTI_PROVIDER_ENABLED_SEARCH", valuePath: "providers.enabledSearch" };
}
if (category === "playlist") {
return { envKey: "MULTI_PROVIDER_ENABLED_PLAYLIST", valuePath: "providers.enabledPlaylist" };
}
const group = this.providerGroup(category);
return group ? { envKey: group.envKey, valuePath: null, group } : null;
}
capabilityProviders(category) {
const config = this.capabilityConfig(category);
if (!config) {
return [];
}
if (config.valuePath) {
const configured = splitCsv(getPathValue(this.config, config.valuePath, ""));
return configured.length ? configured : asArray(this.providerGroup(category)?.providers);
}
return asArray(config.group?.providers);
}
providerCapabilityEnabled(provider, category) {
const providerId = String(provider.id || provider.Id || "").toLowerCase();
return this.capabilityProviders(category).includes(providerId);
}
async toggleProviderCapability(provider, category, enabled) {
const providerId = String(provider.id || provider.Id || "").toLowerCase();
const config = this.capabilityConfig(category);
if (!providerId || !config) {
return;
}
const providers = this.capabilityProviders(category);
const nextProviders = enabled
? joinCsv([...providers, providerId])
: joinCsv(providers.filter((item) => item !== providerId));
await API.updateConfig(config.envKey, nextProviders);
if (config.valuePath) {
const nextConfig = structuredClone(this.config || {});
setPathValue(nextConfig, config.valuePath, nextProviders);
this.config = nextConfig;
}
if (config.group) {
this.schema = {
...this.schema,
priorityGroups: this.schema.priorityGroups.map((group) =>
group.id === config.group.id ? { ...group, providers: splitCsv(nextProviders) } : group,
),
};
}
this.restartKeys = new Set([...this.restartKeys, config.envKey]);
this.toast(`${provider.name || provider.Name} ${category} ${enabled ? "enabled" : "disabled"}`);
}
async submitAppleSetup(event) {
event.preventDefault();
const file = event.currentTarget.querySelector("input[type='file']")?.files?.[0];
if (!file) {
this.serviceResults = { ...this.serviceResults, applemusic: { state: "error", message: "Select an APK or APKM first." } };
return;
}
this.appleMusicUploadProgress = 1;
this.serviceResults = { ...this.serviceResults, applemusic: { state: "running", message: "Uploading Apple Music package..." } };
try {
await API.appleMusicSetup(file, (progress) => { this.appleMusicUploadProgress = progress; });
await this.loadAppleMusicStatus();
this.serviceResults = { ...this.serviceResults, applemusic: { state: "success", message: "Apple Music package staged." } };
} catch (error) {
this.serviceResults = { ...this.serviceResults, applemusic: { state: "error", message: error.message } };
} finally {
this.appleMusicUploadProgress = 0;
}
}
async submitAppleLogin(event) {
event.preventDefault();
const data = new FormData(event.currentTarget);
this.serviceResults = { ...this.serviceResults, applemusic: { state: "running", message: "Starting Apple Music login..." } };
try {
const result = await API.appleMusicLogin(data.get("username"), data.get("password"));
this.appleMusicStatus = { ...(this.appleMusicStatus || {}), account: result, login_state: result.state, logged_in: result.state === "authenticated" };
this.serviceResults = {
...this.serviceResults,
applemusic: {
state: isAwaitingApple2fa(result) ? "warning" : "success",
message: isAwaitingApple2fa(result) ? "Apple Music needs a 2FA code." : "Apple Music login completed.",
},
};
event.currentTarget.reset();
await this.loadAppleMusicStatus();
} catch (error) {
this.serviceResults = { ...this.serviceResults, applemusic: { state: "error", message: error.message } };
}
}
async submitApple2fa(event) {
event.preventDefault();
const code = new FormData(event.currentTarget).get("code");
this.serviceResults = { ...this.serviceResults, applemusic: { state: "running", message: "Submitting Apple Music 2FA..." } };
try {
const result = await API.appleMusic2fa(code);
this.appleMusicStatus = { ...(this.appleMusicStatus || {}), account: result, login_state: result.state, logged_in: result.state === "authenticated" };
this.serviceResults = { ...this.serviceResults, applemusic: { state: "success", message: "Apple Music 2FA accepted." } };
event.currentTarget.reset();
await this.loadAppleMusicStatus();
} catch (error) {
this.serviceResults = { ...this.serviceResults, applemusic: { state: "error", message: error.message } };
}
}
render() {
if (this.loading) {
return html`<div class="app-loading"><div class="chip">Loading Allstarr</div></div>`;
@@ -608,6 +856,12 @@ class AllstarrApp extends LitElement {
</nav>
<div class="sidebar-footer">
<div>Signed in as <strong>${display(this.session?.name || this.session?.Name)}</strong></div>
<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>
<button class="ghost" @click=${this.logout}>Logout</button>
</div>
</aside>
@@ -739,13 +993,13 @@ class AllstarrApp extends LitElement {
}
renderLibrary() {
const [, sub = "overview"] = routeParts(this.route);
const [, sub = "link"] = 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>
<p>Playlist linking, injected playlists, mappings, external discovery, and kept files.</p>
</div>
</div>
${this.renderLibraryNav(sub)}
@@ -756,21 +1010,18 @@ class AllstarrApp extends LitElement {
sub === "migration" ? this.renderSongMigration() :
sub === "kept" ? this.renderKeptDownloads() :
sub === "external" ? this.renderExternalPlaylistExplorer() :
this.renderLibraryOverview()}
this.renderLinkPlaylists()}
</section>
`;
}
renderLibraryNav(active) {
const items = [
["overview", "Overview"],
["link", "Link playlists"],
["injected", "Injected"],
["mappings", "Mappings"],
["missing", "Missing"],
["migration", "Migration"],
["kept", "Kept"],
["external", "External playlists"],
["kept", "Kept"],
];
return html`
<nav class="subnav">
@@ -1241,21 +1492,78 @@ class AllstarrApp extends LitElement {
}
renderProviderCard(provider) {
const status = provider.id === "applemusic" && this.appleMusicStatus
? (this.appleMusicStatus.logged_in || this.appleMusicStatus.account?.state === "authenticated" ? "configured" : "needs_config")
: provider.status;
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 class="provider-brand">
<span class="provider-logo provider-${provider.id}">${providerMark(provider)}</span>
<div class="provider-title">
<strong>${provider.name}</strong>
<span>${provider.id === "musicbrainz" ? "Genre enrichment" : "Provider"}</span>
</div>
</div>
<span class="status-chip ${provider.status}">${titleCase(provider.status)}</span>
<span class="status-chip ${status}">${titleCase(status)}</span>
</div>
<div class="chip-list">
<div class="chip-list capability-list">
${asArray(provider.categories).map((category) => this.renderCapabilityPill(provider, category))}
${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>
${provider.id === "applemusic" ? this.renderAppleMusicManager() : nothing}
</div>
`;
}
renderCapabilityPill(provider, category) {
const enabled = this.providerCapabilityEnabled(provider, category);
return html`
<button
class="chip capability-pill ${enabled ? "success" : "muted-chip"}"
title=${`${enabled ? "Disable" : "Enable"} ${provider.name} for ${category}`}
@click=${() => this.toggleProviderCapability(provider, category, !enabled)}>
${titleCase(category)}
</button>
`;
}
renderAppleMusicManager() {
const status = this.appleMusicStatus || {};
const account = status.account || {};
const loginState = status.login_state || account.state || (status.logged_in ? "authenticated" : "logged_out");
const result = this.serviceResults.applemusic;
const uploadProgress = this.appleMusicUploadProgress;
return html`
<div class="inline-panel">
<div class="stat-list compact">
<div class="stat-row"><span>Native libs</span><span class="status-chip ${status.staged ? "configured" : "needs_config"}">${status.staged ? "Staged" : "Not staged"}</span></div>
<div class="stat-row"><span>Wrapper</span><span class="status-chip ${status.daemon_running ? "configured" : "needs_config"}">${status.daemon_running ? "Running" : "Offline"}</span></div>
<div class="stat-row"><span>Session</span><span class="status-chip ${status.logged_in || account.state === "authenticated" ? "configured" : "needs_config"}">${titleCase(loginState)}</span></div>
</div>
<form class="form-stack compact-form" @submit=${this.submitAppleSetup}>
<div class="form-row">
<label>Apple Music APK/APKM</label>
<input type="file" accept=".apk,.apkm,application/vnd.android.package-archive">
</div>
<button>Stage package</button>
${uploadProgress ? html`<div class="progress indeterminate" style=${`--progress:${uploadProgress}%`}><span></span></div>` : nothing}
</form>
<form class="form-stack compact-form" @submit=${this.submitAppleLogin}>
<div class="form-row"><label>Apple ID</label><input name="username" autocomplete="username" required></div>
<div class="form-row"><label>Password</label><input name="password" type="password" autocomplete="current-password" required></div>
<button class="primary">Start login</button>
</form>
${isAwaitingApple2fa({ ...status, state: loginState }) || result?.state === "warning" ? html`
<form class="form-stack compact-form" @submit=${this.submitApple2fa}>
<div class="form-row"><label>2FA code</label><input name="code" inputmode="numeric" autocomplete="one-time-code" required></div>
<button class="primary">Submit 2FA</button>
</form>
` : nothing}
${result ? html`<div class="callout ${result.state}">${result.message}</div>` : nothing}
</div>
`;
}
@@ -1278,14 +1586,8 @@ class AllstarrApp extends LitElement {
`)}
</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 class="chip-list provider-enabled-list">
${this.capabilityProviders(group.id).map((provider) => html`<span class="chip success">${provider}</span>`)}
</div>
` : nothing}
</div>
@@ -1337,7 +1639,8 @@ class AllstarrApp extends LitElement {
<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>`)}
${asArray(item.types || item.Types).map((type) => html`<span class="chip success">${titleCase(type)}</span>`)}
${asArray(item.capabilities || item.Capabilities).map((type) => html`<span class="chip">${titleCase(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>
@@ -1347,18 +1650,24 @@ class AllstarrApp extends LitElement {
<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>
${storeItems.length ? storeItems.map((item) => {
const key = item.id || item.Id || item.displayName || item.DisplayName;
const action = this.extensionActions[key];
const installedItem = Boolean(item.isInstalled || item.IsInstalled);
return 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=${installedItem || Boolean(action)} @click=${() => this.installExtension(item)}>
${installedItem ? "Installed" : action || "Install"}
</button>
</div>
${action ? html`<div class="progress indeterminate"><span></span></div>` : nothing}
</div>
</div>
`) : html`<div class="empty">Load the store to browse extensions.</div>`}
`;
}) : html`<div class="empty">Load the store to browse extensions.</div>`}
</div>
</div>
</div>
@@ -1438,10 +1747,12 @@ class AllstarrApp extends LitElement {
<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 class="actions scrobble-actions">
<button @click=${() => this.runServiceAction("lastfm", API.testLastFm)}>Test Last.fm</button>
<button @click=${() => this.runServiceAction("listenbrainz", API.testListenBrainz)}>Test ListenBrainz</button>
</div>
${this.serviceResults.lastfm ? html`<div class="callout ${this.serviceResults.lastfm.state}">Last.fm: ${this.serviceResults.lastfm.message}</div>` : nothing}
${this.serviceResults.listenbrainz ? html`<div class="callout ${this.serviceResults.listenbrainz.state}">ListenBrainz: ${this.serviceResults.listenbrainz.message}</div>` : nothing}
`;
}
@@ -1579,11 +1890,16 @@ class AllstarrApp extends LitElement {
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;
const title = current ? display(current.title || current.Title, "Active download") : "No active playback";
const artist = current ? display(current.artist || current.Artist) : "Queue is idle";
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 class="now-track">
<img class="art" src="/placeholder.png" alt="">
<div>
<div class="now-title">${title}</div>
<div class="now-meta">${artist}</div>
</div>
</div>
<div class="progress" style=${`--progress:${progress}%`}><span></span></div>
</footer>