mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
feat(webui): refine provider integration controls
This commit is contained in:
5 files changed
+645
-59
No files matched your search
@@ -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"),
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user