mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
feat(apple-music): unify gamdl-aio sidecar, add Apple Music provider support & Guided Setup Wizard
This commit is contained in:
25 files changed
+1981
-178
No files matched your search
+4
-3
@@ -3,11 +3,12 @@
|
||||
BACKEND_TYPE=Jellyfin
|
||||
|
||||
# ===== ADMIN NETWORK ACCESS (PORT 5275) =====
|
||||
# Keep false to bind admin UI to localhost only (recommended)
|
||||
# Set true only if you need LAN access from another device
|
||||
# Keep false to bind admin UI inside the process/container to localhost only.
|
||||
# Docker host-port, LAN, and reverse-proxy access require true plus a trusted CIDR below.
|
||||
ADMIN_BIND_ANY_IP=false
|
||||
|
||||
# Comma-separated trusted CIDR ranges allowed to access admin port when bind is enabled
|
||||
# Comma-separated trusted CIDR ranges allowed to access admin port when bind is enabled.
|
||||
# For a reverse proxy, include the proxy's Docker network CIDR.
|
||||
# Examples: 192.168.1.0/24,10.0.0.0/8
|
||||
ADMIN_TRUSTED_SUBNETS=
|
||||
|
||||
|
||||
@@ -4,6 +4,30 @@ This document provides detailed configuration options for Allstarr.
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Admin Dashboard Network Access
|
||||
|
||||
The media-client proxy and the admin dashboard use different ports:
|
||||
|
||||
| Port | Purpose |
|
||||
|------|---------|
|
||||
| `8080` (host port `5274` in the provided Compose file) | Jellyfin/Subsonic proxy traffic |
|
||||
| `5275` | Allstarr admin dashboard and admin API |
|
||||
|
||||
Opening port `8080` in a browser does not open the Allstarr dashboard. For
|
||||
Docker, LAN, or reverse-proxy access to the dashboard, configure:
|
||||
|
||||
```dotenv
|
||||
ADMIN_BIND_ANY_IP=true
|
||||
ADMIN_TRUSTED_SUBNETS=192.168.1.0/24
|
||||
```
|
||||
|
||||
`ADMIN_TRUSTED_SUBNETS` is a comma-separated CIDR allowlist. If a reverse proxy
|
||||
connects over a Docker network, allow that Docker network's CIDR and route the
|
||||
dashboard service to container port `5275`, not `8080`.
|
||||
|
||||
Do not publish the dashboard without an additional private-network or
|
||||
authenticated-access boundary.
|
||||
|
||||
### Backend Selection
|
||||
|
||||
| Setting | Description |
|
||||
|
||||
@@ -32,11 +32,28 @@ docker-compose ps
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
The proxy will be available at `http://localhost:5274`.
|
||||
The media-client proxy will be available at `http://localhost:5274`.
|
||||
|
||||
Port `5274` maps to the container's proxy port (`8080`). It does not serve the
|
||||
Allstarr dashboard. The dashboard uses the separate admin port (`5275`).
|
||||
|
||||
## Web Dashboard
|
||||
|
||||
Allstarr includes a web UI for easy configuration and playlist management, accessible at `http://localhost:5275`
|
||||
Allstarr includes a web UI for easy configuration and playlist management,
|
||||
accessible at `http://localhost:5275`.
|
||||
|
||||
The admin listener is localhost-only by default. When Allstarr runs in Docker,
|
||||
set the following values in `.env` before accessing it through a host port,
|
||||
LAN address, or reverse proxy:
|
||||
|
||||
```dotenv
|
||||
ADMIN_BIND_ANY_IP=true
|
||||
ADMIN_TRUSTED_SUBNETS=192.168.1.0/24
|
||||
```
|
||||
|
||||
Replace the example CIDR with the network that should be allowed. For a reverse
|
||||
proxy on a Docker network, include that Docker network's CIDR instead. Keep the
|
||||
dashboard behind a private network, VPN, or an authenticated access proxy.
|
||||
<img width="1664" height="1101" alt="image" src="https://github.com/user-attachments/assets/9159100b-7e11-449e-8530-517d336d6bd2" />
|
||||
|
||||
### Features
|
||||
@@ -81,6 +98,11 @@ There's an environment variable to modify this.
|
||||
|
||||
This service only exposes ports internally. You can use nginx to proxy to it, however PLEASE take significant precautions before exposing this! Everyone decides their own level of risk, but this is currently untested, potentially dangerous software, with almost unfettered access to your Jellyfin server. My recommendation is use Tailscale or something similar!
|
||||
|
||||
The example below targets port `8080` and is for Jellyfin/Subsonic client
|
||||
traffic. It does not expose the Allstarr dashboard. To proxy the dashboard,
|
||||
create a separate protected virtual host targeting port `5275`, and configure
|
||||
`ADMIN_BIND_ANY_IP` and `ADMIN_TRUSTED_SUBNETS` as described above.
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
|
||||
@@ -138,6 +138,7 @@ public class ConfigControllerAuthorizationTests
|
||||
Options.Create(new DeezerSettings()),
|
||||
Options.Create(new QobuzSettings()),
|
||||
Options.Create(new SquidWTFSettings()),
|
||||
Options.Create(new AppleMusicSettings()),
|
||||
Options.Create(new MusicBrainzSettings()),
|
||||
Options.Create(new SpotifyImportSettings()),
|
||||
Options.Create(new ScrobblingSettings()),
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using allstarr.Models.Settings;
|
||||
@@ -59,7 +60,8 @@ public class JellyfinProxyServiceTests
|
||||
Options.Create(_settings),
|
||||
httpContextAccessor,
|
||||
mockLogger.Object,
|
||||
_cache);
|
||||
_cache,
|
||||
new ConfigurationBuilder().Build());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -340,6 +342,35 @@ public class JellyfinProxyServiceTests
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPassthroughResponseAsync_WithPlaylistSortParams_PreservesSortQuery()
|
||||
{
|
||||
// Arrange
|
||||
HttpRequestMessage? captured = null;
|
||||
_mockHandler.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"Items\":[]}")
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await _service.GetPassthroughResponseAsync(
|
||||
"Playlists/playlist-123/Items?Fields=SortName&UserId=user-abc&StartIndex=0&Limit=100&SortBy=Album&SortOrder=Descending");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(captured);
|
||||
var query = captured!.RequestUri!.Query;
|
||||
Assert.Contains("SortBy=Album", query);
|
||||
Assert.Contains("SortOrder=Descending", query);
|
||||
Assert.Contains("StartIndex=0", query);
|
||||
Assert.Contains("Limit=100", query);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPassthroughResponseAsync_WithClientAuth_ForwardsAuthHeader()
|
||||
{
|
||||
@@ -636,7 +667,8 @@ public class JellyfinProxyServiceTests
|
||||
Options.Create(_settings),
|
||||
httpContextAccessor,
|
||||
mockLogger.Object,
|
||||
cache);
|
||||
cache,
|
||||
new ConfigurationBuilder().Build());
|
||||
|
||||
// Act
|
||||
var result = await service.StreamAudioAsync("song-123", CancellationToken.None);
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Jellyfin;
|
||||
@@ -189,7 +190,8 @@ public class JellyfinSessionManagerTests
|
||||
Options.Create(settings),
|
||||
httpContextAccessor,
|
||||
NullLogger<JellyfinProxyService>.Instance,
|
||||
cache);
|
||||
cache,
|
||||
new ConfigurationBuilder().Build());
|
||||
}
|
||||
|
||||
private sealed class TestHttpClientFactory : IHttpClientFactory
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using allstarr.Models.Settings;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin/applemusic")]
|
||||
public class AppleMusicController : ControllerBase
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly AppleMusicSettings _settings;
|
||||
private readonly ILogger<AppleMusicController> _logger;
|
||||
|
||||
public AppleMusicController(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<AppleMusicSettings> settings,
|
||||
ILogger<AppleMusicController> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient("AppleMusic");
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
|
||||
_httpClient.BaseAddress = new Uri(_settings.BaseUrl.TrimEnd('/') + "/");
|
||||
_httpClient.Timeout = TimeSpan.FromMinutes(10); // High timeout for file uploads & setup
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<IActionResult> GetStatus()
|
||||
{
|
||||
try
|
||||
{
|
||||
var res = await _httpClient.GetAsync("api/health");
|
||||
if (!res.IsSuccessStatusCode)
|
||||
{
|
||||
return StatusCode((int)res.StatusCode, await res.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
var json = await res.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return Ok(doc.RootElement);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to connect to Apple Music sidecar");
|
||||
return StatusCode(503, new { error = "Apple Music sidecar container is offline or unreachable." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("setup")]
|
||||
public async Task<IActionResult> Setup([FromForm] IFormFile file)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
return BadRequest(new { error = "No file uploaded" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var content = new MultipartFormDataContent();
|
||||
using var fileStream = file.OpenReadStream();
|
||||
using var streamContent = new StreamContent(fileStream);
|
||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue(file.ContentType ?? "application/octet-stream");
|
||||
content.Add(streamContent, "file", file.FileName);
|
||||
|
||||
_logger.LogInformation("Forwarding Apple Music APK/APKM setup upload to sidecar...");
|
||||
var res = await _httpClient.PostAsync("api/setup", content);
|
||||
|
||||
var json = await res.Content.ReadAsStringAsync();
|
||||
if (!res.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Setup failed on sidecar: {Message}", json);
|
||||
return StatusCode((int)res.StatusCode, json);
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return Ok(doc.RootElement);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Setup proxy call failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
public async Task<IActionResult> Login([FromBody] JsonElement credentials)
|
||||
{
|
||||
try
|
||||
{
|
||||
var res = await _httpClient.PostAsJsonAsync("api/login", credentials);
|
||||
var json = await res.Content.ReadAsStringAsync();
|
||||
|
||||
if (res.StatusCode == System.Net.HttpStatusCode.Accepted || res.StatusCode == System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return StatusCode((int)res.StatusCode, doc.RootElement);
|
||||
}
|
||||
|
||||
return StatusCode((int)res.StatusCode, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Login proxy call failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("login/2fa")]
|
||||
public async Task<IActionResult> Login2fa([FromBody] JsonElement code)
|
||||
{
|
||||
try
|
||||
{
|
||||
var res = await _httpClient.PostAsJsonAsync("api/login/2fa", code);
|
||||
var json = await res.Content.ReadAsStringAsync();
|
||||
|
||||
if (res.IsSuccessStatusCode)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return Ok(doc.RootElement);
|
||||
}
|
||||
|
||||
return StatusCode((int)res.StatusCode, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "2FA verification proxy call failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ public class ConfigController : ControllerBase
|
||||
private readonly DeezerSettings _deezerSettings;
|
||||
private readonly QobuzSettings _qobuzSettings;
|
||||
private readonly SquidWTFSettings _squidWtfSettings;
|
||||
private readonly AppleMusicSettings _appleMusicSettings;
|
||||
private readonly MusicBrainzSettings _musicBrainzSettings;
|
||||
private readonly SpotifyImportSettings _spotifyImportSettings;
|
||||
private readonly ScrobblingSettings _scrobblingSettings;
|
||||
@@ -41,6 +42,7 @@ public class ConfigController : ControllerBase
|
||||
IOptions<DeezerSettings> deezerSettings,
|
||||
IOptions<QobuzSettings> qobuzSettings,
|
||||
IOptions<SquidWTFSettings> squidWtfSettings,
|
||||
IOptions<AppleMusicSettings> appleMusicSettings,
|
||||
IOptions<MusicBrainzSettings> musicBrainzSettings,
|
||||
IOptions<SpotifyImportSettings> spotifyImportSettings,
|
||||
IOptions<ScrobblingSettings> scrobblingSettings,
|
||||
@@ -56,6 +58,7 @@ public class ConfigController : ControllerBase
|
||||
_deezerSettings = deezerSettings.Value;
|
||||
_qobuzSettings = qobuzSettings.Value;
|
||||
_squidWtfSettings = squidWtfSettings.Value;
|
||||
_appleMusicSettings = appleMusicSettings.Value;
|
||||
_musicBrainzSettings = musicBrainzSettings.Value;
|
||||
_spotifyImportSettings = spotifyImportSettings.Value;
|
||||
_scrobblingSettings = scrobblingSettings.Value;
|
||||
@@ -213,6 +216,11 @@ public class ConfigController : ControllerBase
|
||||
quality = GetEnvString(envVars, "SQUIDWTF_QUALITY", _squidWtfSettings.Quality ?? "LOSSLESS"),
|
||||
minRequestIntervalMs = GetEnvInt(envVars, "SQUIDWTF_MIN_REQUEST_INTERVAL_MS", _squidWtfSettings.MinRequestIntervalMs)
|
||||
},
|
||||
appleMusic = new
|
||||
{
|
||||
baseUrl = GetEnvString(envVars, "APPLE_MUSIC_AIO_URL", _appleMusicSettings.BaseUrl ?? "http://gamdl-aio:8000"),
|
||||
quality = GetEnvString(envVars, "APPLE_MUSIC_QUALITY", _appleMusicSettings.Quality ?? "alac-16-44")
|
||||
},
|
||||
musicBrainz = new
|
||||
{
|
||||
enabled = GetEnvBool(envVars, "MUSICBRAINZ_ENABLED", _musicBrainzSettings.Enabled),
|
||||
|
||||
@@ -1279,7 +1279,7 @@ public class PlaylistController : ControllerBase
|
||||
}
|
||||
|
||||
var normalizedProvider = (provider ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (normalizedProvider != "squidwtf" && normalizedProvider != "deezer" && normalizedProvider != "qobuz")
|
||||
if (normalizedProvider != "squidwtf" && normalizedProvider != "deezer" && normalizedProvider != "qobuz" && normalizedProvider != "applemusic")
|
||||
{
|
||||
return BadRequest(new { error = "Unsupported provider" });
|
||||
}
|
||||
@@ -1666,6 +1666,7 @@ public class PlaylistController : ControllerBase
|
||||
"squidwtf" or "squid-wtf" or "squid_wtf" or "tidal" => "squidwtf",
|
||||
"deezer" => "deezer",
|
||||
"qobuz" => "qobuz",
|
||||
"applemusic" or "apple-music" or "apple_music" => "applemusic",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -1728,6 +1729,7 @@ public class PlaylistController : ControllerBase
|
||||
"squidwtf" => $"https://www.tidal.com/track/{externalId}",
|
||||
"deezer" => $"https://www.deezer.com/track/{externalId}",
|
||||
"qobuz" => $"https://open.qobuz.com/track/{externalId}",
|
||||
"applemusic" => $"https://music.apple.com/us/song/{externalId}",
|
||||
_ => externalId
|
||||
};
|
||||
}
|
||||
@@ -1793,6 +1795,7 @@ public class PlaylistController : ControllerBase
|
||||
MusicService.Deezer => "deezer",
|
||||
MusicService.Qobuz => "qobuz",
|
||||
MusicService.SquidWTF => "squidwtf",
|
||||
MusicService.AppleMusic => "applemusic",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,7 +59,8 @@ public class RequestLoggingMiddleware
|
||||
|
||||
// Log request details
|
||||
var requestLog = new StringBuilder();
|
||||
requestLog.AppendLine($"📥 HTTP {request.Method} {request.Path}{queryStringForLog}");
|
||||
var requestUrlForLog = $"{request.Scheme}://{request.Host}{request.Path}{queryStringForLog}";
|
||||
requestLog.AppendLine($"📥 HTTP {request.Method} {requestUrlForLog}");
|
||||
requestLog.AppendLine($" Host: {request.Host}");
|
||||
requestLog.AppendLine($" Content-Type: {request.ContentType ?? "(none)"}");
|
||||
requestLog.AppendLine($" Content-Length: {request.ContentLength?.ToString() ?? "(none)"}");
|
||||
@@ -106,9 +107,9 @@ public class RequestLoggingMiddleware
|
||||
|
||||
// Log response
|
||||
_logger.LogInformation(
|
||||
"📤 HTTP {Method} {Path} → {StatusCode} ({ElapsedMs}ms)",
|
||||
"📤 HTTP {Method} {Url} → {StatusCode} ({ElapsedMs}ms)",
|
||||
request.Method,
|
||||
request.Path,
|
||||
requestUrlForLog,
|
||||
context.Response.StatusCode,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
@@ -116,9 +117,9 @@ public class RequestLoggingMiddleware
|
||||
{
|
||||
stopwatch.Stop();
|
||||
_logger.LogError(ex,
|
||||
"❌ HTTP {Method} {Path} → EXCEPTION ({ElapsedMs}ms)",
|
||||
"❌ HTTP {Method} {Url} → EXCEPTION ({ElapsedMs}ms)",
|
||||
request.Method,
|
||||
request.Path,
|
||||
requestUrlForLog,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
throw;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace allstarr.Models.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the Apple Music (gamdl-aio) service
|
||||
/// </summary>
|
||||
public class AppleMusicSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Base URL of the gamdl-aio sidecar container
|
||||
/// </summary>
|
||||
public string BaseUrl { get; set; } = "http://gamdl-aio:8000";
|
||||
|
||||
/// <summary>
|
||||
/// Preferred quality tier:
|
||||
/// - alac-16-44: 16-bit/44.1kHz ALAC (standard CD Lossless, default)
|
||||
/// - alac-24-96: 24-bit/96kHz ALAC (Hi-Res Lossless)
|
||||
/// - alac-24-192: 24-bit/192kHz ALAC (Highest Hi-Res Lossless)
|
||||
/// - aac-320: 320kbps AAC (Standard Lossy)
|
||||
/// - aac-96: 96kbps AAC (Low Quality Lossy)
|
||||
/// </summary>
|
||||
public string? Quality { get; set; } = "alac-16-44";
|
||||
}
|
||||
@@ -91,7 +91,12 @@ public enum MusicService
|
||||
/// <summary>
|
||||
/// SquidWTF music service
|
||||
/// </summary>
|
||||
SquidWTF
|
||||
SquidWTF,
|
||||
|
||||
/// <summary>
|
||||
/// Apple Music via gamdl-aio sidecar
|
||||
/// </summary>
|
||||
AppleMusic
|
||||
}
|
||||
|
||||
public class SubsonicSettings
|
||||
|
||||
@@ -3,6 +3,7 @@ using allstarr.Services;
|
||||
using allstarr.Services.Deezer;
|
||||
using allstarr.Services.Qobuz;
|
||||
using allstarr.Services.SquidWTF;
|
||||
using allstarr.Services.AppleMusic;
|
||||
using allstarr.Services.Local;
|
||||
using allstarr.Services.Validation;
|
||||
using allstarr.Services.Subsonic;
|
||||
@@ -159,6 +160,7 @@ builder.Services.AddControllers()
|
||||
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddHttpClient("SquidWTF");
|
||||
builder.Services.AddHttpClient("AppleMusic");
|
||||
builder.Services.ConfigureAll<HttpClientFactoryOptions>(options =>
|
||||
{
|
||||
options.HttpMessageHandlerBuilderActions.Add(builder =>
|
||||
@@ -224,6 +226,8 @@ builder.Services.Configure<QobuzSettings>(
|
||||
builder.Configuration.GetSection("Qobuz"));
|
||||
builder.Services.Configure<SquidWTFSettings>(
|
||||
builder.Configuration.GetSection("SquidWTF"));
|
||||
builder.Services.Configure<AppleMusicSettings>(
|
||||
builder.Configuration.GetSection("AppleMusic"));
|
||||
builder.Services.Configure<RedisSettings>(
|
||||
builder.Configuration.GetSection("Redis"));
|
||||
builder.Services.Configure<CacheSettings>(
|
||||
@@ -624,6 +628,11 @@ else if (musicService == MusicService.SquidWTF)
|
||||
sp.GetRequiredService<OdesliService>(),
|
||||
squidWtfStreamingUrls));
|
||||
}
|
||||
else if (musicService == MusicService.AppleMusic)
|
||||
{
|
||||
builder.Services.AddSingleton<IMusicMetadataService, AppleMusicMetadataService>();
|
||||
builder.Services.AddSingleton<IDownloadService, AppleMusicDownloadService>();
|
||||
}
|
||||
|
||||
// Register ParallelMetadataService to race all registered providers for faster searches
|
||||
builder.Services.AddSingleton<ParallelMetadataService>();
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Download;
|
||||
using allstarr.Services.Local;
|
||||
using allstarr.Services.Common;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using IOFile = System.IO.File;
|
||||
|
||||
namespace allstarr.Services.AppleMusic;
|
||||
|
||||
public class AppleMusicDownloadService : BaseDownloadService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly AppleMusicSettings _appleMusicSettings;
|
||||
|
||||
protected override string ProviderName => "applemusic";
|
||||
|
||||
public AppleMusicDownloadService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IConfiguration configuration,
|
||||
ILocalLibraryService localLibraryService,
|
||||
IMusicMetadataService metadataService,
|
||||
IOptions<SubsonicSettings> subsonicSettings,
|
||||
IOptions<AppleMusicSettings> appleMusicSettings,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<AppleMusicDownloadService> logger)
|
||||
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient("AppleMusic");
|
||||
_appleMusicSettings = appleMusicSettings.Value;
|
||||
|
||||
_httpClient.Timeout = TimeSpan.FromMinutes(5);
|
||||
_minRequestIntervalMs = 200;
|
||||
}
|
||||
|
||||
public override async Task<bool> IsAvailableAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var res = await _httpClient.GetAsync(_appleMusicSettings.BaseUrl.TrimEnd('/') + "/api/health");
|
||||
return res.IsSuccessStatusCode;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task<string> DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken)
|
||||
{
|
||||
var quality = _appleMusicSettings.Quality ?? "alac-16-44";
|
||||
return await DownloadTrackWithQualityInternalAsync(trackId, song, quality, cancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task<string> DownloadTrackWithQualityAsync(
|
||||
string trackId, Song song, StreamQuality quality, CancellationToken cancellationToken)
|
||||
{
|
||||
// Map StreamQuality tier to gamdl-aio quality
|
||||
var qualityStr = quality switch
|
||||
{
|
||||
StreamQuality.High => "aac-320",
|
||||
StreamQuality.Low => "aac-96",
|
||||
_ => _appleMusicSettings.Quality ?? "alac-16-44"
|
||||
};
|
||||
|
||||
return await DownloadTrackWithQualityInternalAsync(trackId, song, qualityStr, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<string> DownloadTrackWithQualityInternalAsync(
|
||||
string trackId, Song song, string quality, CancellationToken cancellationToken)
|
||||
{
|
||||
var songId = BuildTrackedSongId(trackId);
|
||||
var basePath = CurrentStorageMode == StorageMode.Cache
|
||||
? Path.Combine(DownloadPath, "cache") : Path.Combine(DownloadPath, "permanent");
|
||||
|
||||
var artistForPath = song.AlbumArtist ?? song.Artist;
|
||||
|
||||
// gamdl-aio outputs FLAC audio stream
|
||||
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, ".flac", "applemusic", trackId);
|
||||
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
||||
EnsureDirectoryExists(albumFolder);
|
||||
|
||||
outputPath = PathHelper.ResolveUniquePath(outputPath);
|
||||
|
||||
var streamUrl = $"{_appleMusicSettings.BaseUrl.TrimEnd('/')}/api/stream/{trackId}?quality={quality}";
|
||||
Logger.LogInformation("Downloading Apple Music track {TrackId} at quality {Quality} from sidecar...", trackId, quality);
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, streamUrl);
|
||||
var res = await _httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
res.EnsureSuccessStatusCode();
|
||||
|
||||
await using var responseStream = await res.Content.ReadAsStreamAsync(cancellationToken);
|
||||
await using var outputFile = IOFile.Create(outputPath);
|
||||
var totalBytes = res.Content.Headers.ContentLength;
|
||||
var buffer = new byte[81920];
|
||||
long totalBytesRead = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var bytesRead = await responseStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken);
|
||||
if (bytesRead <= 0) break;
|
||||
|
||||
await outputFile.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
|
||||
totalBytesRead += bytesRead;
|
||||
|
||||
if (totalBytes.HasValue && totalBytes.Value > 0)
|
||||
{
|
||||
SetDownloadProgress(songId, (double)totalBytesRead / totalBytes.Value);
|
||||
}
|
||||
}
|
||||
|
||||
await outputFile.DisposeAsync();
|
||||
SetDownloadProgress(songId, 1.0);
|
||||
|
||||
// Write tags and cover art
|
||||
await WriteMetadataAsync(outputPath, song, cancellationToken);
|
||||
Logger.LogInformation("Successfully saved and tagged Apple Music track {TrackId} -> {Path}", trackId, outputPath);
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
protected override Task ConvertToSpotifyIdAsync(string externalProvider, string externalId)
|
||||
{
|
||||
// No conversion needed for Spotify mapping in base downloader
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Search;
|
||||
using allstarr.Models.Subsonic;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace allstarr.Services.AppleMusic;
|
||||
|
||||
public class AppleMusicMetadataService : IMusicMetadataService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly AppleMusicSettings _settings;
|
||||
private readonly ILogger<AppleMusicMetadataService> _logger;
|
||||
|
||||
public AppleMusicMetadataService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<AppleMusicSettings> settings,
|
||||
ILogger<AppleMusicMetadataService> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient("AppleMusic");
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
|
||||
_httpClient.BaseAddress = new Uri(_settings.BaseUrl.TrimEnd('/') + "/");
|
||||
}
|
||||
|
||||
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"api/search?q={Uri.EscapeDataString(query)}&type=song&limit={limit}";
|
||||
var results = await _httpClient.GetFromJsonAsync<List<GamdlSongResult>>(url, cancellationToken);
|
||||
|
||||
if (results == null) return new List<Song>();
|
||||
|
||||
return results.Select(r => new Song
|
||||
{
|
||||
Id = $"ext-applemusic-song-{r.Id}",
|
||||
Title = r.Title,
|
||||
Artist = r.Artist,
|
||||
Artists = new List<string> { r.Artist },
|
||||
Album = r.Album,
|
||||
Duration = r.Duration,
|
||||
Track = r.TrackNumber,
|
||||
CoverArtUrl = r.CoverUrl,
|
||||
CoverArtUrlLarge = r.CoverUrl,
|
||||
Isrc = r.Isrc,
|
||||
ExternalProvider = "applemusic",
|
||||
ExternalId = r.Id,
|
||||
IsLocal = false
|
||||
}).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to search Apple Music songs for query: {Query}", query);
|
||||
return new List<Song>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"api/search?q={Uri.EscapeDataString(query)}&type=album&limit={limit}";
|
||||
var results = await _httpClient.GetFromJsonAsync<List<GamdlAlbumResult>>(url, cancellationToken);
|
||||
|
||||
if (results == null) return new List<Album>();
|
||||
|
||||
return results.Select(r => new Album
|
||||
{
|
||||
Id = $"ext-applemusic-album-{r.Id}",
|
||||
Title = r.Title,
|
||||
Artist = r.Artist,
|
||||
CoverArtUrl = r.CoverUrl,
|
||||
Year = !string.IsNullOrEmpty(r.ReleaseDate) && DateTime.TryParse(r.ReleaseDate, out var dt) ? dt.Year : null,
|
||||
ExternalProvider = "applemusic",
|
||||
ExternalId = r.Id
|
||||
}).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to search Apple Music albums for query: {Query}", query);
|
||||
return new List<Album>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"api/search?q={Uri.EscapeDataString(query)}&type=artist&limit={limit}";
|
||||
var results = await _httpClient.GetFromJsonAsync<List<GamdlArtistResult>>(url, cancellationToken);
|
||||
|
||||
if (results == null) return new List<Artist>();
|
||||
|
||||
return results.Select(r => new Artist
|
||||
{
|
||||
Id = $"ext-applemusic-artist-{r.Id}",
|
||||
Name = r.Name,
|
||||
ExternalProvider = "applemusic",
|
||||
ExternalId = r.Id
|
||||
}).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to search Apple Music artists for query: {Query}", query);
|
||||
return new List<Artist>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken);
|
||||
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken);
|
||||
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken);
|
||||
|
||||
await Task.WhenAll(songsTask, albumsTask, artistsTask);
|
||||
|
||||
return new SearchResult
|
||||
{
|
||||
Songs = songsTask.Result,
|
||||
Albums = albumsTask.Result,
|
||||
Artists = artistsTask.Result
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<Song?> GetSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (externalProvider != "applemusic") return null;
|
||||
|
||||
try
|
||||
{
|
||||
var url = $"api/song/{externalId}";
|
||||
var r = await _httpClient.GetFromJsonAsync<GamdlSongDetail>(url, cancellationToken);
|
||||
|
||||
if (r == null) return null;
|
||||
|
||||
return new Song
|
||||
{
|
||||
Id = $"ext-applemusic-song-{r.Id}",
|
||||
Title = r.Title,
|
||||
Artist = r.Artist,
|
||||
Artists = new List<string> { r.Artist },
|
||||
Album = r.Album,
|
||||
Duration = r.Duration,
|
||||
Track = r.TrackNumber,
|
||||
DiscNumber = r.DiscNumber,
|
||||
CoverArtUrl = r.CoverUrl,
|
||||
CoverArtUrlLarge = r.CoverUrl,
|
||||
Isrc = r.Isrc,
|
||||
ReleaseDate = r.ReleaseDate,
|
||||
Copyright = r.Copyright,
|
||||
Composer = r.Composer,
|
||||
Genre = r.Genre,
|
||||
ExternalProvider = "applemusic",
|
||||
ExternalId = r.Id,
|
||||
IsLocal = false
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get Apple Music song details for ID: {ExternalId}", externalId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Song?> FindSongByIsrcAsync(string isrc, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Fallback to song search by ISRC if not explicitly supported via an endpoint
|
||||
var results = await SearchSongsAsync(isrc, 1, cancellationToken);
|
||||
return results.FirstOrDefault();
|
||||
}
|
||||
|
||||
public async Task<Album?> GetAlbumAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Stub: Apple Music albums contain tracks. In a complete provider we would fetch tracks from gamdl-aio
|
||||
return new Album
|
||||
{
|
||||
Id = $"ext-applemusic-album-{externalId}",
|
||||
Title = "Unknown Album",
|
||||
Artist = "Unknown Artist",
|
||||
ExternalProvider = "applemusic",
|
||||
ExternalId = externalId
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<Artist?> GetArtistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new Artist
|
||||
{
|
||||
Id = $"ext-applemusic-artist-{externalId}",
|
||||
Name = "Unknown Artist",
|
||||
ExternalProvider = "applemusic",
|
||||
ExternalId = externalId
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new List<Album>();
|
||||
}
|
||||
|
||||
public async Task<List<Song>> GetArtistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new List<Song>();
|
||||
}
|
||||
|
||||
public async Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new List<ExternalPlaylist>();
|
||||
}
|
||||
|
||||
public async Task<ExternalPlaylist?> GetPlaylistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new List<Song>();
|
||||
}
|
||||
|
||||
// --- JSON Mapping Helper Classes ---
|
||||
|
||||
private class GamdlSongResult
|
||||
{
|
||||
[JsonPropertyName("id")] public string Id { get; set; } = string.Empty;
|
||||
[JsonPropertyName("title")] public string Title { get; set; } = string.Empty;
|
||||
[JsonPropertyName("artist")] public string Artist { get; set; } = string.Empty;
|
||||
[JsonPropertyName("album")] public string Album { get; set; } = string.Empty;
|
||||
[JsonPropertyName("duration")] public int Duration { get; set; }
|
||||
[JsonPropertyName("cover_url")] public string CoverUrl { get; set; } = string.Empty;
|
||||
[JsonPropertyName("track_number")] public int? TrackNumber { get; set; }
|
||||
[JsonPropertyName("isrc")] public string? Isrc { get; set; }
|
||||
}
|
||||
|
||||
private class GamdlAlbumResult
|
||||
{
|
||||
[JsonPropertyName("id")] public string Id { get; set; } = string.Empty;
|
||||
[JsonPropertyName("title")] public string Title { get; set; } = string.Empty;
|
||||
[JsonPropertyName("artist")] public string Artist { get; set; } = string.Empty;
|
||||
[JsonPropertyName("cover_url")] public string CoverUrl { get; set; } = string.Empty;
|
||||
[JsonPropertyName("release_date")] public string? ReleaseDate { get; set; }
|
||||
}
|
||||
|
||||
private class GamdlArtistResult
|
||||
{
|
||||
[JsonPropertyName("id")] public string Id { get; set; } = string.Empty;
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private class GamdlSongDetail
|
||||
{
|
||||
[JsonPropertyName("id")] public string Id { get; set; } = string.Empty;
|
||||
[JsonPropertyName("title")] public string Title { get; set; } = string.Empty;
|
||||
[JsonPropertyName("artist")] public string Artist { get; set; } = string.Empty;
|
||||
[JsonPropertyName("album")] public string Album { get; set; } = string.Empty;
|
||||
[JsonPropertyName("duration")] public int Duration { get; set; }
|
||||
[JsonPropertyName("track_number")] public int? TrackNumber { get; set; }
|
||||
[JsonPropertyName("disc_number")] public int? DiscNumber { get; set; }
|
||||
[JsonPropertyName("isrc")] public string? Isrc { get; set; }
|
||||
[JsonPropertyName("cover_url")] public string CoverUrl { get; set; } = string.Empty;
|
||||
[JsonPropertyName("release_date")] public string? ReleaseDate { get; set; }
|
||||
[JsonPropertyName("copyright")] public string? Copyright { get; set; }
|
||||
[JsonPropertyName("composer")] public string? Composer { get; set; }
|
||||
[JsonPropertyName("genre")] public string? Genre { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,9 @@ public static class RuntimeEnvConfiguration
|
||||
["SQUIDWTF_QUALITY"] = ["SquidWTF:Quality"],
|
||||
["SQUIDWTF_MIN_REQUEST_INTERVAL_MS"] = ["SquidWTF:MinRequestIntervalMs"],
|
||||
|
||||
["APPLE_MUSIC_AIO_URL"] = ["AppleMusic:BaseUrl"],
|
||||
["APPLE_MUSIC_QUALITY"] = ["AppleMusic:Quality"],
|
||||
|
||||
["MUSICBRAINZ_ENABLED"] = ["MusicBrainz:Enabled"],
|
||||
["MUSICBRAINZ_USERNAME"] = ["MusicBrainz:Username"],
|
||||
["MUSICBRAINZ_PASSWORD"] = ["MusicBrainz:Password"],
|
||||
|
||||
@@ -26,6 +26,7 @@ public class JellyfinProxyService
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly ILogger<JellyfinProxyService> _logger;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly IConfiguration _configuration;
|
||||
private string? _cachedMusicLibraryId;
|
||||
private bool _libraryIdDetected = false;
|
||||
|
||||
@@ -37,13 +38,15 @@ public class JellyfinProxyService
|
||||
IOptions<JellyfinSettings> settings,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILogger<JellyfinProxyService> logger,
|
||||
RedisCacheService cache)
|
||||
RedisCacheService cache,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient(HttpClientName);
|
||||
_settings = settings.Value;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -174,6 +177,8 @@ public class JellyfinProxyService
|
||||
using var request = CreateClientGetRequest(url, clientHeaders, out var isBrowserStaticRequest, out var isPublicEndpoint);
|
||||
ForwardPassthroughRequestHeaders(clientHeaders, request);
|
||||
|
||||
LogOutboundRequest(HttpMethod.Get, url);
|
||||
|
||||
var response = await _httpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
@@ -191,6 +196,8 @@ public class JellyfinProxyService
|
||||
{
|
||||
using var request = CreateClientGetRequest(url, clientHeaders, out var isBrowserStaticRequest, out var isPublicEndpoint);
|
||||
|
||||
LogOutboundRequest(HttpMethod.Get, url);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
@@ -395,6 +402,8 @@ public class JellyfinProxyService
|
||||
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
LogOutboundRequest(method, url);
|
||||
|
||||
if (isAuthEndpoint)
|
||||
{
|
||||
_logger.LogDebug("{Method} to Jellyfin: {Url} (auth request - body not logged)", method, url);
|
||||
@@ -462,6 +471,8 @@ public class JellyfinProxyService
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("Authorization", GetAuthorizationHeader());
|
||||
|
||||
LogOutboundRequest(HttpMethod.Get, url);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
@@ -487,6 +498,8 @@ public class JellyfinProxyService
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("Authorization", GetAuthorizationHeader());
|
||||
|
||||
LogOutboundRequest(HttpMethod.Get, url);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
@@ -747,6 +760,8 @@ public class JellyfinProxyService
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("Authorization", GetAuthorizationHeader());
|
||||
|
||||
LogOutboundRequest(HttpMethod.Get, url);
|
||||
|
||||
// Forward Range headers for progressive streaming
|
||||
if (incomingRequest.Headers.TryGetValue("Range", out var range))
|
||||
{
|
||||
@@ -945,6 +960,60 @@ public class JellyfinProxyService
|
||||
return url;
|
||||
}
|
||||
|
||||
private void LogOutboundRequest(HttpMethod method, string url)
|
||||
{
|
||||
if (!_configuration.GetValue<bool>("Debug:LogAllRequests"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var urlForLog = _configuration.GetValue<bool>("Debug:RedactSensitiveRequestValues", false)
|
||||
? MaskSensitiveUrl(url)
|
||||
: url;
|
||||
|
||||
_logger.LogInformation("➡️ Jellyfin {Method} {Url}", method.Method, urlForLog);
|
||||
}
|
||||
|
||||
private static string MaskSensitiveUrl(string url)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || string.IsNullOrEmpty(uri.Query))
|
||||
{
|
||||
return url;
|
||||
}
|
||||
|
||||
var query = uri.Query.TrimStart('?');
|
||||
var parts = query
|
||||
.Split('&', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(part =>
|
||||
{
|
||||
var kv = part.Split('=', 2);
|
||||
var key = Uri.UnescapeDataString(kv[0]);
|
||||
return IsSensitiveQueryKey(key)
|
||||
? $"{kv[0]}=<redacted>"
|
||||
: part;
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
if (parts.Length == 0)
|
||||
{
|
||||
return url;
|
||||
}
|
||||
|
||||
return $"{uri.GetLeftPart(UriPartial.Path)}?{string.Join("&", parts)}{uri.Fragment}";
|
||||
}
|
||||
|
||||
private static bool IsSensitiveQueryKey(string key)
|
||||
{
|
||||
return string.Equals(key, "api_key", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, "token", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, "auth", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, "authorization", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, "x-emby-token", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, "x-emby-authorization", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("token", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("auth", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void LogUpstreamFailure(HttpMethod method, HttpStatusCode statusCode, string url, string? responseBody = null)
|
||||
{
|
||||
if (statusCode == HttpStatusCode.Unauthorized)
|
||||
@@ -1017,6 +1086,8 @@ public class JellyfinProxyService
|
||||
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
LogOutboundRequest(HttpMethod.Get, url);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"System.Net.Http.HttpClient.Default.LogicalHandler": "Warning",
|
||||
"System.Net.Http.HttpClient.Default.ClientHandler": "Warning"
|
||||
"System.Net.Http.HttpClient.Default.ClientHandler": "Warning",
|
||||
"System.Net.Http.HttpClient.JellyfinBackend.LogicalHandler": "Warning",
|
||||
"System.Net.Http.HttpClient.JellyfinBackend.ClientHandler": "Warning"
|
||||
}
|
||||
},
|
||||
"Debug": {
|
||||
|
||||
+263
-133
@@ -89,7 +89,7 @@
|
||||
<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="scrobbling">Scrobbling</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="report-issues">Report Issues</button>
|
||||
<button class="sidebar-link" type="button" data-tab="endpoints">API Analytics</button>
|
||||
@@ -109,7 +109,7 @@
|
||||
<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="scrobbling">Scrobbling</div>
|
||||
<div class="tab" data-tab="services">Services</div>
|
||||
<div class="tab" data-tab="config">Configuration</div>
|
||||
<div class="tab" data-tab="report-issues">Report Issues</div>
|
||||
<div class="tab" data-tab="endpoints">API Analytics</div>
|
||||
@@ -117,6 +117,45 @@
|
||||
|
||||
<!-- 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>
|
||||
@@ -480,8 +519,200 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrobbling Tab -->
|
||||
<div class="tab-content" id="tab-scrobbling">
|
||||
<!-- 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>
|
||||
<span class="value" id="config-spotify-enabled">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('SPOTIFY_API_ENABLED', 'Spotify API Enabled', 'toggle')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Session Cookie (sp_dc) <span style="color: var(--error);">*</span></span>
|
||||
<span class="value" id="config-spotify-cookie">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('SPOTIFY_API_SESSION_COOKIE', 'Spotify Session Cookie', 'password', 'Get from browser dev tools while logged into Spotify. Cookie typically lasts ~1 year.')">Update</button>
|
||||
</div>
|
||||
<div class="config-item" style="grid-template-columns: 200px 1fr;">
|
||||
<span class="label">Cookie Age</span>
|
||||
<span class="value" id="config-cookie-age">-</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Cache Duration</span>
|
||||
<span class="value" id="config-cache-duration">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('SPOTIFY_API_CACHE_DURATION_MINUTES', 'Cache Duration (minutes)', 'number')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">ISRC Matching</span>
|
||||
<span class="value" id="config-isrc-matching">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('SPOTIFY_API_PREFER_ISRC_MATCHING', 'Prefer ISRC Matching', 'toggle')">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>SquidWTF / Tidal Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">Quality</span>
|
||||
<span class="value" id="config-squid-quality">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('SQUIDWTF_QUALITY', 'SquidWTF Quality', 'select', 'HI_RES_LOSSLESS: 24-bit/192kHz FLAC (highest)\\nLOSSLESS: 16-bit/44.1kHz FLAC (default)\\nHIGH: 320kbps AAC\\nLOW: 96kbps AAC', ['HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'LOW'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Request Interval</span>
|
||||
<span class="value" id="config-squid-ratelimit">200 ms</span>
|
||||
<button
|
||||
onclick="openEditSetting('SQUIDWTF_MIN_REQUEST_INTERVAL_MS', 'SquidWTF Request Interval', 'number', 'Minimum milliseconds between API requests (default: 200)')">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Deezer Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">ARL Token</span>
|
||||
<span class="value" id="config-deezer-arl">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('DEEZER_ARL', 'Deezer ARL Token', 'password', 'Get from browser cookies while logged into Deezer')">Update</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Quality</span>
|
||||
<span class="value" id="config-deezer-quality">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('DEEZER_QUALITY', 'Deezer Quality', 'select', '', ['FLAC', 'MP3_320', 'MP3_128'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Request Interval</span>
|
||||
<span class="value" id="config-deezer-ratelimit">200 ms</span>
|
||||
<button
|
||||
onclick="openEditSetting('DEEZER_MIN_REQUEST_INTERVAL_MS', 'Deezer Request Interval', 'number', 'Minimum milliseconds between API requests (default: 200)')">Edit</button>
|
||||
</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>
|
||||
<span class="value" id="config-qobuz-token">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('QOBUZ_USER_AUTH_TOKEN', 'Qobuz User Auth Token', 'password', 'Get from browser while logged into Qobuz')">Update</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Preferred Quality</span>
|
||||
<span class="value" id="config-qobuz-quality">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('QOBUZ_QUALITY', 'Qobuz Quality', 'select', 'Default: FLAC', ['FLAC', 'FLAC_24_HIGH', 'FLAC_24_LOW', 'FLAC_16', 'MP3_320'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Request Interval</span>
|
||||
<span class="value" id="config-qobuz-ratelimit">200 ms</span>
|
||||
<button
|
||||
onclick="openEditSetting('QOBUZ_MIN_REQUEST_INTERVAL_MS', 'Qobuz Request Interval', 'number', 'Minimum milliseconds between API requests (default: 200)')">Edit</button>
|
||||
</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>
|
||||
<span class="value" id="config-applemusic-baseurl">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('APPLE_MUSIC_AIO_URL', 'gamdl-aio Sidecar URL', 'text', 'Base URL of the gamdl-aio sidecar container (default: http://gamdl-aio:8000)')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Preferred Quality</span>
|
||||
<span class="value" id="config-applemusic-quality">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('APPLE_MUSIC_QUALITY', 'Apple Music Quality', 'select', 'Select preferred audio codec tier', ['alac-16-44', 'alac-24-96', 'alac-24-192', 'aac-320', 'aac-96'])">Edit</button>
|
||||
</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%;"></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;">
|
||||
@@ -582,6 +813,30 @@
|
||||
<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>
|
||||
<span class="value" id="config-musicbrainz-enabled">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('MUSICBRAINZ_ENABLED', 'MusicBrainz Enabled', 'toggle')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Username</span>
|
||||
<span class="value" id="config-musicbrainz-username">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('MUSICBRAINZ_USERNAME', 'MusicBrainz Username', 'text', 'Your MusicBrainz username')">Update</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Password</span>
|
||||
<span class="value" id="config-musicbrainz-password">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('MUSICBRAINZ_PASSWORD', 'MusicBrainz Password', 'password', 'Your MusicBrainz password')">Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Tab -->
|
||||
@@ -599,7 +854,7 @@
|
||||
<span class="label">Music Service <span style="color: var(--error);">*</span></span>
|
||||
<span class="value" id="config-music-service">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('MUSIC_SERVICE', 'Music Service', 'select', 'Choose your music download provider', ['SquidWTF', 'Deezer', 'Qobuz'])">Edit</button>
|
||||
onclick="openEditSetting('MUSIC_SERVICE', 'Music Service', 'select', 'Choose your music download provider', ['SquidWTF', 'Deezer', 'Qobuz', 'AppleMusic'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Storage Mode</span>
|
||||
@@ -684,134 +939,8 @@
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<span class="value" id="config-spotify-enabled">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('SPOTIFY_API_ENABLED', 'Spotify API Enabled', 'toggle')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Session Cookie (sp_dc) <span style="color: var(--error);">*</span></span>
|
||||
<span class="value" id="config-spotify-cookie">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('SPOTIFY_API_SESSION_COOKIE', 'Spotify Session Cookie', 'password', 'Get from browser dev tools while logged into Spotify. Cookie typically lasts ~1 year.')">Update</button>
|
||||
</div>
|
||||
<div class="config-item" style="grid-template-columns: 200px 1fr;">
|
||||
<span class="label">Cookie Age</span>
|
||||
<span class="value" id="config-cookie-age">-</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Cache Duration</span>
|
||||
<span class="value" id="config-cache-duration">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('SPOTIFY_API_CACHE_DURATION_MINUTES', 'Cache Duration (minutes)', 'number')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">ISRC Matching</span>
|
||||
<span class="value" id="config-isrc-matching">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('SPOTIFY_API_PREFER_ISRC_MATCHING', 'Prefer ISRC Matching', 'toggle')">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Deezer Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">ARL Token</span>
|
||||
<span class="value" id="config-deezer-arl">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('DEEZER_ARL', 'Deezer ARL Token', 'password', 'Get from browser cookies while logged into Deezer')">Update</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Quality</span>
|
||||
<span class="value" id="config-deezer-quality">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('DEEZER_QUALITY', 'Deezer Quality', 'select', '', ['FLAC', 'MP3_320', 'MP3_128'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Request Interval</span>
|
||||
<span class="value" id="config-deezer-ratelimit">200 ms</span>
|
||||
<button
|
||||
onclick="openEditSetting('DEEZER_MIN_REQUEST_INTERVAL_MS', 'Deezer Request Interval', 'number', 'Minimum milliseconds between API requests (default: 200)')">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>SquidWTF / Tidal Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">Quality</span>
|
||||
<span class="value" id="config-squid-quality">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('SQUIDWTF_QUALITY', 'SquidWTF Quality', 'select', 'HI_RES_LOSSLESS: 24-bit/192kHz FLAC (highest)\\nLOSSLESS: 16-bit/44.1kHz FLAC (default)\\nHIGH: 320kbps AAC\\nLOW: 96kbps AAC', ['HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'LOW'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Request Interval</span>
|
||||
<span class="value" id="config-squid-ratelimit">200 ms</span>
|
||||
<button
|
||||
onclick="openEditSetting('SQUIDWTF_MIN_REQUEST_INTERVAL_MS', 'SquidWTF Request Interval', 'number', 'Minimum milliseconds between API requests (default: 200)')">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>MusicBrainz Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">Enabled</span>
|
||||
<span class="value" id="config-musicbrainz-enabled">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('MUSICBRAINZ_ENABLED', 'MusicBrainz Enabled', 'toggle')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Username</span>
|
||||
<span class="value" id="config-musicbrainz-username">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('MUSICBRAINZ_USERNAME', 'MusicBrainz Username', 'text', 'Your MusicBrainz username')">Update</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Password</span>
|
||||
<span class="value" id="config-musicbrainz-password">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('MUSICBRAINZ_PASSWORD', 'MusicBrainz Password', 'password', 'Your MusicBrainz password')">Update</button>
|
||||
</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>
|
||||
<span class="value" id="config-qobuz-token">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('QOBUZ_USER_AUTH_TOKEN', 'Qobuz User Auth Token', 'password', 'Get from browser while logged into Qobuz')">Update</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Preferred Quality</span>
|
||||
<span class="value" id="config-qobuz-quality">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('QOBUZ_QUALITY', 'Qobuz Quality', 'select', 'Default: FLAC', ['FLAC', 'FLAC_24_HIGH', 'FLAC_24_LOW', 'FLAC_16', 'MP3_320'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Request Interval</span>
|
||||
<span class="value" id="config-qobuz-ratelimit">200 ms</span>
|
||||
<button
|
||||
onclick="openEditSetting('QOBUZ_MIN_REQUEST_INTERVAL_MS', 'Qobuz Request Interval', 'number', 'Minimum milliseconds between API requests (default: 200)')">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- External services settings moved to Services tab -->
|
||||
|
||||
<div class="card">
|
||||
<h2>Jellyfin Settings</h2>
|
||||
<div class="config-section">
|
||||
@@ -1238,6 +1367,7 @@
|
||||
<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">
|
||||
|
||||
@@ -383,6 +383,27 @@ function normalizeExternalIdForProvider(externalId, provider) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (normalizedProvider === "applemusic") {
|
||||
if (/^\d+$/.test(trimmedId)) {
|
||||
return trimmedId;
|
||||
}
|
||||
try {
|
||||
const url = new URL(trimmedId);
|
||||
const queryId = url.searchParams.get("i")?.trim();
|
||||
if (queryId && /^\d+$/.test(queryId)) {
|
||||
return queryId;
|
||||
}
|
||||
const pathSegments = url.pathname.split("/").filter(Boolean);
|
||||
const lastSegment = pathSegments[pathSegments.length - 1] || "";
|
||||
if (/^\d+$/.test(lastSegment)) {
|
||||
return lastSegment;
|
||||
}
|
||||
} catch {
|
||||
return trimmedId;
|
||||
}
|
||||
return trimmedId;
|
||||
}
|
||||
|
||||
if (normalizedProvider !== "squidwtf") {
|
||||
return trimmedId;
|
||||
}
|
||||
@@ -432,7 +453,9 @@ export function selectExternalTrack(
|
||||
? "Deezer"
|
||||
: normalizedProvider === "qobuz"
|
||||
? "Qobuz"
|
||||
: providerSelect.value;
|
||||
: normalizedProvider === "applemusic" || normalizedProvider === "apple-music"
|
||||
? "AppleMusic"
|
||||
: providerSelect.value;
|
||||
|
||||
providerSelect.value = providerOptionValue;
|
||||
const selectedProvider = providerOptionValue.toLowerCase();
|
||||
@@ -595,6 +618,11 @@ export function validateExternalMapping(externalId, provider) {
|
||||
showToast("Qobuz ID format appears invalid", "error");
|
||||
valid = false;
|
||||
}
|
||||
} else if (provider === "applemusic") {
|
||||
if (!/^\d+$/.test(externalId) && !externalId.startsWith("http")) {
|
||||
showToast("Apple Music ID should be numeric or a full URL", "error");
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
|
||||
const saveBtn = document.getElementById("map-save-btn");
|
||||
|
||||
@@ -231,6 +231,18 @@ const SETTINGS_REGISTRY = {
|
||||
},
|
||||
200,
|
||||
),
|
||||
APPLE_MUSIC_AIO_URL: textBinding(
|
||||
(config) => config?.appleMusic?.baseUrl ?? "http://gamdl-aio:8000",
|
||||
(config, value) => {
|
||||
ensureConfigSection(config, "appleMusic").baseUrl = value;
|
||||
},
|
||||
),
|
||||
APPLE_MUSIC_QUALITY: textBinding(
|
||||
(config) => config?.appleMusic?.quality ?? "alac-16-44",
|
||||
(config, value) => {
|
||||
ensureConfigSection(config, "appleMusic").quality = value;
|
||||
},
|
||||
),
|
||||
MUSICBRAINZ_ENABLED: toggleBinding(
|
||||
(config) => config?.musicBrainz?.enabled ?? false,
|
||||
(config, value) => {
|
||||
|
||||
@@ -860,6 +860,10 @@ export function updateDownloadsUI(data) {
|
||||
}
|
||||
|
||||
export function updateConfigUI(data) {
|
||||
window.lastConfigData = data;
|
||||
if (typeof currentWizardStep !== 'undefined') {
|
||||
renderWizardStep(currentWizardStep);
|
||||
}
|
||||
document.getElementById("config-backend-type").textContent =
|
||||
data.backendType || "Jellyfin";
|
||||
document.getElementById("config-music-service").textContent =
|
||||
@@ -912,6 +916,10 @@ export function updateConfigUI(data) {
|
||||
data.squidWtf.quality;
|
||||
document.getElementById("config-squid-ratelimit").textContent =
|
||||
(data.squidWtf.minRequestIntervalMs || 200) + " ms";
|
||||
document.getElementById("config-applemusic-baseurl").textContent =
|
||||
data.appleMusic.baseUrl || "http://gamdl-aio:8000";
|
||||
document.getElementById("config-applemusic-quality").textContent =
|
||||
data.appleMusic.quality || "alac-16-44";
|
||||
document.getElementById("config-musicbrainz-enabled").textContent = data
|
||||
.musicBrainz.enabled
|
||||
? "Yes"
|
||||
@@ -1207,3 +1215,702 @@ export function showPlaylistRebuildingIndicator(playlistName) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Apple Music Sidecar Manager Logic ---
|
||||
|
||||
// Poll status of Apple Music container
|
||||
async function pollAppleMusicStatus() {
|
||||
const card = document.getElementById("applemusic-manager-card");
|
||||
if (!card) return;
|
||||
|
||||
// Only poll if AppleMusic is the active provider or if we are looking at configuration
|
||||
const currentTab = document.querySelector(".sidebar-link.active")?.getAttribute("data-tab");
|
||||
if (currentTab !== "config") return;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/admin/applemusic/status");
|
||||
if (!res.ok) {
|
||||
throw new Error("Sidecar returned error status");
|
||||
}
|
||||
const data = await res.json();
|
||||
|
||||
updateAmIndicator("am-staged-status", data.staged);
|
||||
updateAmIndicator("am-daemon-status", data.daemon_running);
|
||||
updateAmIndicator("am-auth-status", data.logged_in);
|
||||
|
||||
// Conditionally show setup, login, 2fa forms
|
||||
if (!data.staged) {
|
||||
document.getElementById("am-upload-section").style.display = "block";
|
||||
document.getElementById("am-login-section").style.display = "none";
|
||||
document.getElementById("am-tfa-section").style.display = "none";
|
||||
} else if (!data.logged_in) {
|
||||
document.getElementById("am-upload-section").style.display = "none";
|
||||
if (document.getElementById("am-tfa-section").style.display !== "block") {
|
||||
document.getElementById("am-login-section").style.display = "block";
|
||||
}
|
||||
} else {
|
||||
document.getElementById("am-upload-section").style.display = "none";
|
||||
document.getElementById("am-login-section").style.display = "none";
|
||||
document.getElementById("am-tfa-section").style.display = "none";
|
||||
}
|
||||
} catch (error) {
|
||||
updateAmIndicator("am-staged-status", false);
|
||||
updateAmIndicator("am-daemon-status", false);
|
||||
updateAmIndicator("am-auth-status", false);
|
||||
document.getElementById("am-upload-section").style.display = "none";
|
||||
document.getElementById("am-login-section").style.display = "none";
|
||||
document.getElementById("am-tfa-section").style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function updateAmIndicator(id, active) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
if (active) {
|
||||
el.innerHTML = '<span class="status-indicator green"></span> Active / Yes';
|
||||
} else {
|
||||
el.innerHTML = '<span class="status-indicator red"></span> Offline / No';
|
||||
}
|
||||
}
|
||||
|
||||
// Upload APK
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const apkInput = document.getElementById("am-apk-input");
|
||||
if (apkInput) {
|
||||
apkInput.addEventListener("change", (e) => {
|
||||
if (e.target.files.length > 0) {
|
||||
uploadAppleMusicApk(e.target.files[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Start polling status
|
||||
setInterval(pollAppleMusicStatus, 3500);
|
||||
|
||||
// Also poll once immediately after tab load or startup
|
||||
setTimeout(pollAppleMusicStatus, 1000);
|
||||
});
|
||||
|
||||
async function uploadAppleMusicApk(file) {
|
||||
const progressContainer = document.getElementById("am-upload-progress-container");
|
||||
const progressBar = document.getElementById("am-upload-progress-bar");
|
||||
const progressText = document.getElementById("am-upload-progress-text");
|
||||
|
||||
progressContainer.style.display = "block";
|
||||
progressBar.style.width = "0%";
|
||||
progressText.textContent = "Uploading... 0%";
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
xhr.open("POST", "/api/admin/applemusic/setup", true);
|
||||
|
||||
xhr.upload.addEventListener("progress", (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const percentComplete = Math.round((e.loaded / e.total) * 100);
|
||||
progressBar.style.width = percentComplete + "%";
|
||||
progressText.textContent = `Uploading... ${percentComplete}%`;
|
||||
}
|
||||
});
|
||||
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
progressText.textContent = "Staging libraries on sidecar... Please wait";
|
||||
setTimeout(() => {
|
||||
progressContainer.style.display = "none";
|
||||
pollAppleMusicStatus();
|
||||
}, 5000);
|
||||
} else {
|
||||
progressContainer.style.display = "none";
|
||||
alert("Staging failed: " + xhr.responseText);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
progressContainer.style.display = "none";
|
||||
alert("Connection error occurred during upload.");
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
// Login
|
||||
async function submitAppleMusicLogin() {
|
||||
const username = document.getElementById("am-username-input").value.trim();
|
||||
const password = document.getElementById("am-password-input").value;
|
||||
|
||||
if (!username || !password) {
|
||||
alert("Please enter both Apple ID and password.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/admin/applemusic/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
if (res.status === 200) {
|
||||
alert("Login successful!");
|
||||
pollAppleMusicStatus();
|
||||
} else if (res.status === 202) {
|
||||
// 2FA required
|
||||
document.getElementById("am-login-section").style.display = "none";
|
||||
document.getElementById("am-tfa-section").style.display = "block";
|
||||
} else {
|
||||
const text = await res.text();
|
||||
alert("Login failed: " + text);
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Error logging in: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
const text = await res.text();
|
||||
alert("Verification failed: " + text);
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Error verifying code: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Interactive Setup Wizard Logic ---
|
||||
|
||||
let currentWizardStep = 1;
|
||||
|
||||
function initSetupWizard() {
|
||||
const container = document.getElementById("wizard-step-container");
|
||||
if (!container) return;
|
||||
|
||||
// Render step 1 immediately
|
||||
renderWizardStep(1);
|
||||
}
|
||||
|
||||
function renderWizardStep(step) {
|
||||
currentWizardStep = step;
|
||||
const container = document.getElementById("wizard-step-container");
|
||||
if (!container) return;
|
||||
|
||||
// Update Stepper indicators
|
||||
const steps = document.querySelectorAll(".setup-step");
|
||||
steps.forEach((el, index) => {
|
||||
const stepNum = index + 1;
|
||||
el.classList.remove("active", "completed");
|
||||
if (stepNum === step) {
|
||||
el.classList.add("active");
|
||||
} else if (stepNum < step) {
|
||||
el.classList.add("completed");
|
||||
}
|
||||
});
|
||||
|
||||
// Update Stepper Line Progress Bar
|
||||
const progressBar = document.getElementById("wizard-progress-bar");
|
||||
if (progressBar) {
|
||||
progressBar.style.width = ((step - 1) / 3) * 100 + "%";
|
||||
}
|
||||
|
||||
// Update actions buttons visibility/text
|
||||
const backBtn = document.getElementById("wizard-back-btn");
|
||||
const skipBtn = document.getElementById("wizard-skip-btn");
|
||||
const nextBtn = document.getElementById("wizard-next-btn");
|
||||
|
||||
if (backBtn) {
|
||||
backBtn.style.visibility = step === 1 ? "hidden" : "visible";
|
||||
}
|
||||
if (skipBtn) {
|
||||
skipBtn.style.display = step === 4 ? "none" : "inline-block";
|
||||
}
|
||||
if (nextBtn) {
|
||||
nextBtn.textContent = step === 4 ? "Restart Allstarr" : "Next Step →";
|
||||
nextBtn.className = step === 4 ? "primary success" : "primary";
|
||||
if (step === 4) {
|
||||
nextBtn.onclick = completeWizardSetup;
|
||||
} else {
|
||||
nextBtn.onclick = nextWizardStep;
|
||||
}
|
||||
}
|
||||
|
||||
// Inject Step Contents HTML
|
||||
const config = window.lastConfigData || {};
|
||||
|
||||
if (step === 1) {
|
||||
container.innerHTML = `
|
||||
<h3>🔌 Step 1: Connect your media server backend</h3>
|
||||
<p class="step-desc">Allstarr syncs local playlists and streams audio files through your server. Let's link it now.</p>
|
||||
|
||||
<div class="form-group mb-12">
|
||||
<label class="text-secondary" style="display:block; margin-bottom:6px;">Backend Type</label>
|
||||
<select id="wizard-backend-type" onchange="toggleWizardBackendFields()" style="width:100%; padding:10px; background:var(--bg-secondary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary);">
|
||||
<option value="Jellyfin" ${config.backendType === "Jellyfin" ? "selected" : ""}>Jellyfin (Recommended)</option>
|
||||
<option value="Subsonic" ${config.backendType === "Subsonic" ? "selected" : ""}>Subsonic (Navidrome, Gonic, Airsonic)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-12">
|
||||
<label class="text-secondary" style="display:block; margin-bottom:6px;">Server URL</label>
|
||||
<input type="text" id="wizard-backend-url" value="${config.backendType === "Subsonic" ? (config.subsonic?.url || "") : (config.jellyfin?.url || "")}" placeholder="e.g. http://192.168.1.100:8096" style="width:100%; padding:10px; background:var(--bg-secondary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary);">
|
||||
</div>
|
||||
|
||||
<div id="wizard-jellyfin-fields" style="display: ${config.backendType === "Subsonic" ? "none" : "block"};">
|
||||
<div class="form-group mb-12">
|
||||
<label class="text-secondary" style="display:block; margin-bottom:6px;">API Key</label>
|
||||
<input type="password" id="wizard-jellyfin-api-key" value="${config.jellyfin?.apiKey || ""}" placeholder="Enter Jellyfin API key..." style="width:100%; padding:10px; 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" style="display:block; margin-bottom:6px;">User ID (optional)</label>
|
||||
<input type="text" id="wizard-jellyfin-user-id" value="${config.jellyfin?.userId || ""}" placeholder="Optional. Required to sync playlists." style="width:100%; padding:10px; background:var(--bg-secondary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary);">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="wizard-subsonic-fields" style="display: ${config.backendType === "Subsonic" ? "block" : "none"};">
|
||||
<p class="text-secondary" style="font-size:0.85rem; margin-top:8px;">Subsonic auth handles token generation automatically from settings after save.</p>
|
||||
</div>
|
||||
`;
|
||||
} else if (step === 2) {
|
||||
container.innerHTML = `
|
||||
<h3>🎵 Step 2: Choose and authorize your music provider</h3>
|
||||
<p class="step-desc">Allstarr uses a streaming provider to search and fetch decryptable audio files.</p>
|
||||
|
||||
<div class="form-group mb-16">
|
||||
<label class="text-secondary" style="display:block; margin-bottom:6px;">Music Service Provider</label>
|
||||
<select id="wizard-music-service" onchange="toggleWizardServiceFields()" style="width:100%; padding:10px; background:var(--bg-secondary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary);">
|
||||
<option value="SquidWTF" ${config.musicService === "SquidWTF" ? "selected" : ""}>SquidWTF (Tidal)</option>
|
||||
<option value="Deezer" ${config.musicService === "Deezer" ? "selected" : ""}>Deezer</option>
|
||||
<option value="Qobuz" ${config.musicService === "Qobuz" ? "selected" : ""}>Qobuz</option>
|
||||
<option value="AppleMusic" ${config.musicService === "AppleMusic" ? "selected" : ""}>Apple Music (gamdl-aio sidecar)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Deezer Form -->
|
||||
<div id="wizard-deezer-fields" style="display: ${config.musicService === "Deezer" ? "block" : "none"};">
|
||||
<div class="form-group mb-12">
|
||||
<label class="text-secondary" style="display:block; margin-bottom:6px;">ARL Token</label>
|
||||
<input type="password" id="wizard-deezer-arl" placeholder="Paste your Deezer ARL cookie token..." style="width:100%; padding:10px; background:var(--bg-secondary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary);">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Qobuz Form -->
|
||||
<div id="wizard-qobuz-fields" style="display: ${config.musicService === "Qobuz" ? "block" : "none"};">
|
||||
<div class="form-group mb-12">
|
||||
<label class="text-secondary" style="display:block; margin-bottom:6px;">Qobuz Auth Token</label>
|
||||
<input type="password" id="wizard-qobuz-token" value="${config.qobuz?.userAuthToken || ""}" placeholder="Paste Qobuz Auth Token..." style="width:100%; padding:10px; background:var(--bg-secondary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary);">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Apple Music Form -->
|
||||
<div id="wizard-applemusic-fields" style="display: ${config.musicService === "AppleMusic" ? "block" : "none"};">
|
||||
<div style="background: rgba(59, 130, 246, 0.08); border: 1px solid var(--border); border-radius: 8px; padding: 16px; margin-bottom: 16px;">
|
||||
<h4 style="margin-bottom: 8px;">gamdl-aio Sidecar Setup</h4>
|
||||
|
||||
<div class="config-section" style="margin-bottom:12px; display:grid; gap:8px;">
|
||||
<div class="config-item" style="grid-template-columns: 200px 1fr; border:none; padding:0;">
|
||||
<span class="label">Native Libs Staged:</span>
|
||||
<span class="value" id="wizard-am-staged"><span class="status-indicator red"></span> Offline</span>
|
||||
</div>
|
||||
<div class="config-item" style="grid-template-columns: 200px 1fr; border:none; padding:0;">
|
||||
<span class="label">Subscription Authorized:</span>
|
||||
<span class="value" id="wizard-am-auth"><span class="status-indicator red"></span> Offline</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage APK -->
|
||||
<div id="wizard-am-apk-section" style="display:none; border-top: 1px solid var(--border); padding-top:12px; margin-top:12px;">
|
||||
<p class="text-secondary" style="font-size:0.85rem; margin-bottom:8px;">
|
||||
Staging native FairPlay binaries required. Browse and upload a compatible client APK/APKM (version 3.6.0-beta, build 1109):
|
||||
</p>
|
||||
<input type="file" id="wizard-am-apk-input" accept=".apk,.apkm" style="display:none;">
|
||||
<button type="button" class="primary" onclick="document.getElementById('wizard-am-apk-input').click()">Browse & Upload APK/APKM</button>
|
||||
<div id="wizard-am-progress-container" style="display:none; margin-top:8px;">
|
||||
<div style="background: var(--bg-secondary); border-radius:4px; height:8px; overflow:hidden; position:relative; margin-bottom:4px;">
|
||||
<div id="wizard-am-progress-bar" style="background: var(--accent); width:0%; height:100%;"></div>
|
||||
</div>
|
||||
<span id="wizard-am-progress-text" style="font-size:0.75rem;" class="text-secondary">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login credentials -->
|
||||
<div id="wizard-am-login-section" style="display:none; border-top: 1px solid var(--border); padding-top:12px; margin-top:12px;">
|
||||
<p class="text-secondary" style="font-size:0.85rem; margin-bottom:8px;">Enter Apple Music credentials to login:</p>
|
||||
<div class="form-group mb-8">
|
||||
<input type="email" id="wizard-am-user" placeholder="Apple ID Email" 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">
|
||||
<input type="password" id="wizard-am-pass" placeholder="Password" style="width:100%; padding:8px; background:var(--bg-secondary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary);">
|
||||
</div>
|
||||
<button type="button" class="primary" onclick="submitWizardAmLogin()">Authorize Login</button>
|
||||
</div>
|
||||
|
||||
<!-- 2FA -->
|
||||
<div id="wizard-am-tfa-section" style="display:none; border-top: 1px solid var(--border); padding-top:12px; margin-top:12px;">
|
||||
<p class="text-secondary" style="font-size:0.85rem; margin-bottom:8px;">Enter the 6-digit verification code sent to your devices:</p>
|
||||
<input type="text" id="wizard-am-tfa" placeholder="123456" maxlength="6" style="width:100%; padding:8px; margin-bottom:12px; background:var(--bg-secondary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary); text-align:center; font-size:1.2rem; letter-spacing:4px;">
|
||||
<button type="button" class="primary" onclick="submitWizardAmTfa()">Verify Code</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Start polling status if Apple Music is active
|
||||
if (document.getElementById("wizard-music-service").value === "AppleMusic") {
|
||||
pollWizardAmStatus();
|
||||
setupWizardApkListener();
|
||||
}
|
||||
} else if (step === 3) {
|
||||
container.innerHTML = `
|
||||
<h3>🔑 Step 3: Link Spotify API (Recommended)</h3>
|
||||
<p class="step-desc">Allows Allstarr to import active Spotify playlists, map ISRCs, and fetch lyrics on the fly.</p>
|
||||
|
||||
<div class="form-group mb-16" style="display:flex; align-items:center; gap:10px;">
|
||||
<input type="checkbox" id="wizard-spotify-enabled" ${config.spotifyApi?.enabled ? "checked" : ""} onchange="toggleWizardSpotifyFields()" style="width:20px; height:20px; cursor:pointer;">
|
||||
<label for="wizard-spotify-enabled" style="font-weight:600; cursor:pointer;">Enable Spotify Playlist Sync & Matching</label>
|
||||
</div>
|
||||
|
||||
<div id="wizard-spotify-fields" style="display: ${config.spotifyApi?.enabled ? "block" : "none"};">
|
||||
<div class="form-group mb-12">
|
||||
<label class="text-secondary" style="display:block; margin-bottom:6px;">Spotify Session Cookie (<code>sp_dc</code>)</label>
|
||||
<input type="password" id="wizard-spotify-cookie" value="${config.spotifyApi?.sessionCookie || ""}" placeholder="Paste your Spotify sp_dc session cookie..." style="width:100%; padding:10px; background:var(--bg-secondary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary);">
|
||||
<small class="text-secondary" style="margin-top:4px; display:block; line-height:1.4;">
|
||||
Get this from browser dev tools (Application tab -> Cookies) while logged into spotify.com. Key typically lasts ~1 year.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (step === 4) {
|
||||
const backend = config.backendType || "Jellyfin";
|
||||
const service = config.musicService || "SquidWTF";
|
||||
const spotifyActive = config.spotifyApi?.enabled;
|
||||
|
||||
container.innerHTML = `
|
||||
<h3>🎉 Guided Setup Complete!</h3>
|
||||
<p class="step-desc">Your basic configuration is ready. Apply and restart the service to initialize all components.</p>
|
||||
|
||||
<div style="background: rgba(16, 185, 129, 0.08); border: 1px solid var(--success); border-radius: 8px; padding: 16px; margin-bottom: 24px;">
|
||||
<h4 style="color:var(--success); margin-bottom:12px; font-weight:600;">Configuration Checklist:</h4>
|
||||
<ul style="list-style:none; padding:0; display:grid; gap:8px;">
|
||||
<li>✔️ Backend server set to <strong>${backend}</strong></li>
|
||||
<li>✔️ Music downloader set to <strong>${service}</strong></li>
|
||||
<li>${spotifyActive ? "✔️ Spotify integration configured" : "❌ Spotify integration skipped (optional)"}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p class="text-secondary" style="font-size:0.9rem; margin-bottom:12px;">Clicking "Restart Allstarr" will write all configuration properties to your environment and reboot the proxy server.</p>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleWizardBackendFields() {
|
||||
const type = document.getElementById("wizard-backend-type").value;
|
||||
document.getElementById("wizard-jellyfin-fields").style.display = type === "Jellyfin" ? "block" : "none";
|
||||
document.getElementById("wizard-subsonic-fields").style.display = type === "Subsonic" ? "block" : "none";
|
||||
}
|
||||
|
||||
function toggleWizardServiceFields() {
|
||||
const val = document.getElementById("wizard-music-service").value;
|
||||
document.getElementById("wizard-deezer-fields").style.display = val === "Deezer" ? "block" : "none";
|
||||
document.getElementById("wizard-qobuz-fields").style.display = val === "Qobuz" ? "block" : "none";
|
||||
document.getElementById("wizard-applemusic-fields").style.display = val === "AppleMusic" ? "block" : "none";
|
||||
|
||||
if (val === "AppleMusic") {
|
||||
pollWizardAmStatus();
|
||||
setupWizardApkListener();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleWizardSpotifyFields() {
|
||||
const chk = document.getElementById("wizard-spotify-enabled").checked;
|
||||
document.getElementById("wizard-spotify-fields").style.display = chk ? "block" : "none";
|
||||
}
|
||||
|
||||
// Staging Listeners
|
||||
function setupWizardApkListener() {
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById("wizard-am-apk-input");
|
||||
if (input && !input.dataset.hasListener) {
|
||||
input.dataset.hasListener = "true";
|
||||
input.addEventListener("change", (e) => {
|
||||
if (e.target.files.length > 0) {
|
||||
uploadWizardAmApk(e.target.files[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Upload Wizard APK
|
||||
async function uploadWizardAmApk(file) {
|
||||
const progressContainer = document.getElementById("wizard-am-progress-container");
|
||||
const progressBar = document.getElementById("wizard-am-progress-bar");
|
||||
const progressText = document.getElementById("wizard-am-progress-text");
|
||||
|
||||
if (!progressContainer) return;
|
||||
progressContainer.style.display = "block";
|
||||
progressBar.style.width = "0%";
|
||||
progressText.textContent = "Uploading... 0%";
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
xhr.open("POST", "/api/admin/applemusic/setup", true);
|
||||
|
||||
xhr.upload.addEventListener("progress", (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const percentComplete = Math.round((e.loaded / e.total) * 100);
|
||||
progressBar.style.width = percentComplete + "%";
|
||||
progressText.textContent = `Uploading... ${percentComplete}%`;
|
||||
}
|
||||
});
|
||||
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
progressText.textContent = "Staging libraries on sidecar... Please wait";
|
||||
setTimeout(() => {
|
||||
progressContainer.style.display = "none";
|
||||
pollWizardAmStatus();
|
||||
}, 5000);
|
||||
} else {
|
||||
progressContainer.style.display = "none";
|
||||
alert("Staging failed: " + xhr.responseText);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
// Poll Apple Music status in Wizard
|
||||
async function pollWizardAmStatus() {
|
||||
if (currentWizardStep !== 2) return;
|
||||
const service = document.getElementById("wizard-music-service")?.value;
|
||||
if (service !== "AppleMusic") return;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/admin/applemusic/status");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
|
||||
const stagedVal = document.getElementById("wizard-am-staged");
|
||||
const authVal = document.getElementById("wizard-am-auth");
|
||||
|
||||
if (stagedVal) {
|
||||
stagedVal.innerHTML = data.staged
|
||||
? '<span class="status-indicator green"></span> Active / Yes'
|
||||
: '<span class="status-indicator red"></span> Offline / No';
|
||||
}
|
||||
if (authVal) {
|
||||
authVal.innerHTML = data.logged_in
|
||||
? '<span class="status-indicator green"></span> Active / Yes'
|
||||
: '<span class="status-indicator red"></span> Offline / No';
|
||||
}
|
||||
|
||||
// Sections visibility
|
||||
const apkSec = document.getElementById("wizard-am-apk-section");
|
||||
const loginSec = document.getElementById("wizard-am-login-section");
|
||||
const tfaSec = document.getElementById("wizard-am-tfa-section");
|
||||
|
||||
if (!data.staged) {
|
||||
if (apkSec) apkSec.style.display = "block";
|
||||
if (loginSec) loginSec.style.display = "none";
|
||||
if (tfaSec) tfaSec.style.display = "none";
|
||||
} else if (!data.logged_in) {
|
||||
if (apkSec) apkSec.style.display = "none";
|
||||
if (tfaSec && tfaSec.style.display !== "block") {
|
||||
if (loginSec) loginSec.style.display = "block";
|
||||
}
|
||||
} else {
|
||||
if (apkSec) apkSec.style.display = "none";
|
||||
if (loginSec) loginSec.style.display = "none";
|
||||
if (tfaSec) tfaSec.style.display = "none";
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent fail
|
||||
}
|
||||
}
|
||||
|
||||
// Authorize Login in Wizard
|
||||
async function submitWizardAmLogin() {
|
||||
const username = document.getElementById("wizard-am-user").value.trim();
|
||||
const password = document.getElementById("wizard-am-pass").value;
|
||||
if (!username || !password) {
|
||||
alert("Please enter both Apple ID email and password.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/admin/applemusic/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
if (res.status === 200) {
|
||||
alert("Apple Music Login Successful!");
|
||||
pollWizardAmStatus();
|
||||
} else if (res.status === 202) {
|
||||
document.getElementById("wizard-am-login-section").style.display = "none";
|
||||
document.getElementById("wizard-am-tfa-section").style.display = "block";
|
||||
} else {
|
||||
const text = await res.text();
|
||||
alert("Login failed: " + text);
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Error logging in: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 2FA in Wizard
|
||||
async function submitWizardAmTfa() {
|
||||
const code = document.getElementById("wizard-am-tfa").value.trim();
|
||||
if (!code || code.length !== 6) {
|
||||
alert("Please enter the 6-digit code.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/admin/applemusic/login/2fa", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
alert("2FA Verification Successful! Logged In.");
|
||||
pollWizardAmStatus();
|
||||
} else {
|
||||
const text = await res.text();
|
||||
alert("Verification failed: " + text);
|
||||
}
|
||||
} catch (e) {
|
||||
alert("Error verifying code: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Save config on step navigation
|
||||
async function saveWizardStepData(step) {
|
||||
const updates = {};
|
||||
|
||||
if (step === 1) {
|
||||
const type = document.getElementById("wizard-backend-type").value;
|
||||
const url = document.getElementById("wizard-backend-url").value.trim();
|
||||
updates["BACKEND_TYPE"] = type;
|
||||
if (type === "Jellyfin") {
|
||||
updates["JELLYFIN_URL"] = url;
|
||||
updates["JELLYFIN_API_KEY"] = document.getElementById("wizard-jellyfin-api-key").value.trim();
|
||||
updates["JELLYFIN_USER_ID"] = document.getElementById("wizard-jellyfin-user-id").value.trim();
|
||||
} else {
|
||||
updates["SUBSONIC_URL"] = url;
|
||||
}
|
||||
} else if (step === 2) {
|
||||
const service = document.getElementById("wizard-music-service").value;
|
||||
updates["MUSIC_SERVICE"] = service;
|
||||
if (service === "Deezer") {
|
||||
updates["DEEZER_ARL"] = document.getElementById("wizard-deezer-arl").value.trim();
|
||||
} else if (service === "Qobuz") {
|
||||
updates["QOBUZ_USER_AUTH_TOKEN"] = document.getElementById("wizard-qobuz-token").value.trim();
|
||||
}
|
||||
} else if (step === 3) {
|
||||
const spotifyEnabled = document.getElementById("wizard-spotify-enabled").checked;
|
||||
updates["SPOTIFY_API_ENABLED"] = spotifyEnabled ? "true" : "false";
|
||||
if (spotifyEnabled) {
|
||||
updates["SPOTIFY_API_SESSION_COOKIE"] = document.getElementById("wizard-spotify-cookie").value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/admin/config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ updates })
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to save step settings");
|
||||
}
|
||||
// Update local lastConfigData properties
|
||||
if (!window.lastConfigData) window.lastConfigData = {};
|
||||
Object.assign(window.lastConfigData, updates);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Warning: Failed to auto-save step configuration to server: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation Triggers
|
||||
async function nextWizardStep() {
|
||||
await saveWizardStepData(currentWizardStep);
|
||||
if (currentWizardStep < 4) {
|
||||
renderWizardStep(currentWizardStep + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function prevWizardStep() {
|
||||
if (currentWizardStep > 1) {
|
||||
renderWizardStep(currentWizardStep - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function skipWizardStep() {
|
||||
if (currentWizardStep < 4) {
|
||||
renderWizardStep(currentWizardStep + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function jumpToWizardStep(step) {
|
||||
if (step >= 1 && step <= 4) {
|
||||
renderWizardStep(step);
|
||||
}
|
||||
}
|
||||
|
||||
// Final Save & Restart
|
||||
async function completeWizardSetup() {
|
||||
if (confirm("Configuration properties saved. Restart Allstarr container now?")) {
|
||||
try {
|
||||
const res = await fetch("/api/admin/config/restart", { method: "POST" });
|
||||
if (res.ok) {
|
||||
alert("Server is restarting. Please wait 10-15 seconds and refresh this page.");
|
||||
} else {
|
||||
alert("Restart command sent. Please refresh the page in a few moments.");
|
||||
}
|
||||
} catch (e) {
|
||||
alert("Restart requested. Reconnecting shortly.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expose functions globally for HTML triggers
|
||||
window.initSetupWizard = initSetupWizard;
|
||||
window.renderWizardStep = renderWizardStep;
|
||||
window.toggleWizardBackendFields = toggleWizardBackendFields;
|
||||
window.toggleWizardServiceFields = toggleWizardServiceFields;
|
||||
window.toggleWizardSpotifyFields = toggleWizardSpotifyFields;
|
||||
window.nextWizardStep = nextWizardStep;
|
||||
window.prevWizardStep = prevWizardStep;
|
||||
window.skipWizardStep = skipWizardStep;
|
||||
window.jumpToWizardStep = jumpToWizardStep;
|
||||
window.pollWizardAmStatus = pollWizardAmStatus;
|
||||
window.submitWizardAmLogin = submitWizardAmLogin;
|
||||
window.submitWizardAmTfa = submitWizardAmTfa;
|
||||
window.completeWizardSetup = completeWizardSetup;
|
||||
|
||||
// Upload APK
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const apkInput = document.getElementById("am-apk-input");
|
||||
if (apkInput) {
|
||||
apkInput.addEventListener("change", (e) => {
|
||||
if (e.target.files.length > 0) {
|
||||
uploadAppleMusicApk(e.target.files[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Start polling status
|
||||
setInterval(pollAppleMusicStatus, 3500);
|
||||
|
||||
// Also poll once immediately after tab load or startup
|
||||
setTimeout(pollAppleMusicStatus, 1000);
|
||||
|
||||
// Initialize Guided Setup Wizard
|
||||
setTimeout(() => {
|
||||
initSetupWizard();
|
||||
}, 500);
|
||||
});
|
||||
@@ -15,13 +15,13 @@ export function initScrobblingView({
|
||||
}
|
||||
}
|
||||
|
||||
const scrobblingTab = document.querySelector('.tab[data-tab="scrobbling"]');
|
||||
const scrobblingTab = document.querySelector('.tab[data-tab="services"]');
|
||||
if (scrobblingTab) {
|
||||
scrobblingTab.addEventListener("click", onActivateScrobbling);
|
||||
}
|
||||
|
||||
const scrobblingSidebar = document.querySelector(
|
||||
'.sidebar-link[data-tab="scrobbling"]',
|
||||
'.sidebar-link[data-tab="services"]',
|
||||
);
|
||||
if (scrobblingSidebar) {
|
||||
scrobblingSidebar.addEventListener("click", onActivateScrobbling);
|
||||
|
||||
+193
-25
@@ -1,15 +1,19 @@
|
||||
: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;
|
||||
--bg-primary: #080c14;
|
||||
--bg-secondary: #0f1524;
|
||||
--bg-tertiary: #1b2336;
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #94a3b8;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #60a5fa;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
--border: #1e293b;
|
||||
--border-hover: #334155;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -337,15 +341,21 @@ h1 {
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1.1rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -358,14 +368,14 @@ h1 {
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@@ -404,15 +414,22 @@ button {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--border);
|
||||
background: var(--border-hover);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
@@ -423,6 +440,7 @@ button.primary {
|
||||
|
||||
button.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
@@ -431,13 +449,13 @@ button.secondary {
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border-color: var(--error);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: rgba(248, 81, 73, 0.3);
|
||||
background: rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
.map-action-btn {
|
||||
@@ -1589,3 +1607,153 @@ textarea {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* --- Setup Wizard / Guided Stepper Styling --- */
|
||||
.setup-wizard-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 32px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.setup-wizard-card h2 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.setup-wizard-card .wizard-intro {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.setup-stepper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
margin-bottom: 32px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.setup-stepper::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 40px;
|
||||
right: 40px;
|
||||
height: 3px;
|
||||
background: var(--border);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.setup-stepper-progress {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 40px;
|
||||
width: 0%; /* Dynamic width based on current step */
|
||||
height: 3px;
|
||||
background: var(--accent);
|
||||
z-index: 2;
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.setup-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.setup-step-circle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-secondary);
|
||||
border: 3px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.setup-step:hover .setup-step-circle {
|
||||
border-color: var(--border-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.setup-step.active .setup-step-circle {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-primary);
|
||||
color: var(--accent);
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.setup-step.completed .setup-step-circle {
|
||||
border-color: var(--success);
|
||||
background: var(--success);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.setup-step-label {
|
||||
margin-top: 10px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.setup-step.active .setup-step-label {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.setup-step.completed .setup-step-label {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.setup-wizard-content {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.setup-wizard-content h3 {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.setup-wizard-content p.step-desc {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.setup-wizard-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.setup-wizard-actions .right-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -168,6 +168,9 @@ services:
|
||||
- MusicBrainz__Enabled=${MUSICBRAINZ_ENABLED:-true}
|
||||
- MusicBrainz__Username=${MUSICBRAINZ_USERNAME:-}
|
||||
- MusicBrainz__Password=${MUSICBRAINZ_PASSWORD:-}
|
||||
# ===== APPLE MUSIC =====
|
||||
- AppleMusic__BaseUrl=${APPLE_MUSIC_AIO_URL:-http://gamdl-aio:8000}
|
||||
- AppleMusic__Quality=${APPLE_MUSIC_QUALITY:-alac-16-44}
|
||||
volumes:
|
||||
- ${DOWNLOAD_PATH:-./downloads}:/app/downloads
|
||||
- ${KEPT_PATH:-./kept}:/app/kept
|
||||
@@ -177,6 +180,23 @@ services:
|
||||
# Docker socket for self-restart capability (admin UI only)
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
||||
gamdl-aio:
|
||||
build:
|
||||
context: ../gamdl-aio
|
||||
args:
|
||||
TARGET_ARCH: arm64-v8a
|
||||
RUNTIME_PLATFORM: linux/arm64
|
||||
container_name: gamdl-aio
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "8000"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./gamdl-data:/app/data
|
||||
networks:
|
||||
- allstarr-network
|
||||
|
||||
networks:
|
||||
allstarr-network:
|
||||
name: allstarr-network
|
||||
|
||||
Reference in new issue
Block a user