feat(deploy): add optional Spotify lyrics sidecar

This commit is contained in:
joshpatra committed 2026-07-16 20:09:23 -04:00
1 parent 43e9873120
commit 93bbd595fb
10 files changed
+180 -1

No files matched your search

+3
View File
@@ -84,7 +84,10 @@ jobs:
run: dotnet test --configuration Release --no-build --verbosity normal
- name: Validate Compose contracts
env:
SPOTIFY_API_SESSION_COOKIE: ci-placeholder
run: |
docker compose -f docker-compose.yml config --quiet
docker compose -f docker-compose.yml -f docker-compose.dev.yml config --quiet
docker compose -f docker-compose.yml -f docker-compose.aio.yml config --quiet
docker compose -f docker-compose.yml -f docker-compose.spotify-lyrics.yml config --quiet
+5
View File
@@ -62,6 +62,11 @@ dashboard or as `APPLE_DOWNLOAD_URL`. The URL must be the gateway's Allstarr-com
wrapper-v2 address. Adding or removing it does not replace the database, Valkey, application state, or media
volumes. Follow [the Apple download provider procedure](docs/operations/apple-download-provider.md).
The optional Spotify lyrics service is likewise absent from Standard and AIO. Add the pinned
`docker-compose.spotify-lyrics.yml` overlay only when needed, following
[docs/operations/spotify-lyrics-sidecar.md](docs/operations/spotify-lyrics-sidecar.md). Its cookie stays in the host
`.env`; the dashboard migration imports endpoint configuration but never manages Docker or exports provider secrets.
Custom manual deployments may select SQLite explicitly. SQLite bootstrap has an intentional one-shot confirmation requirement, and no automatic Postgres-to-SQLite fallback exists. Follow [docs/operations/storage.md](docs/operations/storage.md) instead of guessing these settings.
### Identity and provider-account ownership
+5
View File
@@ -54,6 +54,11 @@ separately, then give Allstarr its URL through the dashboard or `APPLE_DOWNLOAD_
gateway API, not directly to wrapper-v2. Removing that URL disables Apple download routes without changing Postgres
or media volumes. See [Apple download provider setup](docs/operations/apple-download-provider.md).
Spotify lyrics are optional too. To run the pinned private-network sidecar, add
`docker-compose.spotify-lyrics.yml` to the Compose command and follow the
[Spotify lyrics sidecar guide](docs/operations/spotify-lyrics-sidecar.md). Importing an old `.env` can restore the
endpoint URL, but it cannot start the sidecar or pass a cookie to it.
Client traffic uses `http://localhost:5274`. The separate dashboard is on `http://localhost:5275`. Standard Compose publishes the dashboard on host loopback and only trusts the container gateway needed to cross that mapping. LAN or reverse-proxy access requires `ADMIN_BIND_ADDRESS=0.0.0.0`, `ADMIN_BIND_ANY_IP=true`, and an explicit `ADMIN_TRUSTED_SUBNETS` CIDR. Please keep it behind a private network, VPN, or authenticated access proxy. This software has meaningful access to your media server and provider accounts.
The complete install, backup, restore, and rollback instructions live in [the storage runbook](docs/operations/storage.md). Configuration keys are explained in [CONFIGURATION.md](CONFIGURATION.md).
+18
View File
@@ -88,6 +88,20 @@ public sealed class ComposeContractTests
}
[Fact]
public void SpotifyLyricsOverlay_IsOptionalPinnedAndPrivate()
{
RenderCompose("docker-compose.yml", "docker-compose.spotify-lyrics.yml");
var overlay = File.ReadAllText(Path.Combine(_repositoryRoot, "docker-compose.spotify-lyrics.yml"));
Assert.Contains("akashrchandran/spotify-lyrics-api@sha256:", overlay, StringComparison.Ordinal);
Assert.Contains("SP_DC: ${SPOTIFY_API_SESSION_COOKIE:-}", overlay, StringComparison.Ordinal);
Assert.Contains("SpotifyApi__LyricsApiUrl:", overlay, StringComparison.Ordinal);
Assert.DoesNotContain("ports:", overlay, StringComparison.Ordinal);
Assert.DoesNotContain(":latest", overlay, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("/var/run/docker.sock", overlay, StringComparison.Ordinal);
}
[Fact]
public void RuntimeImage_ContainsBackupToolsAndPinnedDotnetBases()
{
@@ -126,6 +140,10 @@ public sealed class ComposeContractTests
"docker compose -f docker-compose.yml -f docker-compose.aio.yml config --quiet",
workflow,
StringComparison.Ordinal);
Assert.Contains(
"docker compose -f docker-compose.yml -f docker-compose.spotify-lyrics.yml config --quiet",
workflow,
StringComparison.Ordinal);
Assert.DoesNotContain("continue-on-error: true", workflow, StringComparison.Ordinal);
}
+18
View File
@@ -33,6 +33,24 @@ public sealed class LegacyEnvParserTests
Assert.DoesNotContain("deezer-secret", document.SourceSha256, StringComparison.Ordinal);
}
[Fact]
public void Parse_ImportsOptionalProviderEndpointsAsDurableSettings()
{
var document = Parse("""
SPOTIFY_LYRICS_API_URL=http://spotify-lyrics:8080
APPLE_DOWNLOAD_URL=http://apple-gateway:8000
""");
AssertEntry(document, "SPOTIFY_LYRICS_API_URL", LegacyEnvDisposition.DurableSetting,
"import_if_absent", false);
AssertEntry(document, "APPLE_DOWNLOAD_URL", LegacyEnvDisposition.DurableSetting,
"import_if_absent", false);
Assert.Equal("SpotifyApi:LyricsApiUrl",
document.Entries.Single(item => item.Key == "SPOTIFY_LYRICS_API_URL").DurableKey);
Assert.Equal("AppleDownload:BaseUrl",
document.Entries.Single(item => item.Key == "APPLE_DOWNLOAD_URL").DurableKey);
}
[Fact]
public void Parse_RejectsIncompleteProviderBundlesAndIgnoresEmptyValues()
{
@@ -122,6 +122,23 @@ public sealed class WebUiEnvMigrationContractTests
Assert.Contains("No container restart is required for this migration", _script, StringComparison.Ordinal);
}
[Fact]
public void OptionalRuntimeRows_ShowRedactedPostImportDeploymentGuidance()
{
Assert.Contains("migrationOptionalRuntimeServices()", _script, StringComparison.Ordinal);
Assert.Contains("SPOTIFY_LYRICS_API_URL", _script, StringComparison.Ordinal);
Assert.Contains("APPLE_DOWNLOAD_URL", _script, StringComparison.Ordinal);
Assert.Contains("APPLE_MUSIC_AIO_URL", _script, StringComparison.Ordinal);
Assert.Contains("Optional services still need setup", _script, StringComparison.Ordinal);
Assert.Contains("No URL, login, token, or session value is shown here.", _script, StringComparison.Ordinal);
Assert.Contains("docs/operations/spotify-lyrics-sidecar.md", _script, StringComparison.Ordinal);
Assert.Contains("docs/operations/apple-download-provider.md", _script, StringComparison.Ordinal);
Assert.Contains("docker compose -f docker-compose.yml", _script, StringComparison.Ordinal);
Assert.Contains("docker-compose.spotify-lyrics.yml up -d", _script, StringComparison.Ordinal);
Assert.Contains("target=\"_blank\" rel=\"noopener noreferrer\"", _script, StringComparison.Ordinal);
Assert.Contains("this.renderMigrationOptionalRuntimeGuidance()", _script, StringComparison.Ordinal);
}
[Fact]
public void Preview_RedactsSecretsAndGroupsChangesWithWarnings()
{
@@ -66,6 +66,7 @@ public static class LegacyEnvParser
["SPOTIFY_API_CACHE_DURATION_MINUTES"] = "SpotifyApi:CacheDurationMinutes",
["SPOTIFY_API_RATE_LIMIT_DELAY_MS"] = "SpotifyApi:RateLimitDelayMs",
["SPOTIFY_API_PREFER_ISRC_MATCHING"] = "SpotifyApi:PreferIsrcMatching",
["SPOTIFY_LYRICS_API_URL"] = "SpotifyApi:LyricsApiUrl",
["SCROBBLING_ENABLED"] = "Scrobbling:Enabled",
["SCROBBLING_LOCAL_TRACKS_ENABLED"] = "Scrobbling:LocalTracksEnabled",
["SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED"] = "Scrobbling:SyntheticLocalPlayedSignalEnabled",
@@ -150,7 +151,6 @@ public static class LegacyEnvParser
"ALLSTARR_ALLOW_GLOBAL_ACCOUNTS", "ALLSTARR_ALLOW_GLOBAL_PERSONAL_ACCOUNTS",
"ALLSTARR_SHARED_DOWNLOADER_ACCOUNT_ID", "LIBRARY_DOWNLOAD_PATH", "LIBRARY_KEPT_PATH",
"DOWNLOAD_PATH", "KEPT_PATH", "CACHE_PATH", "REDIS_ENABLED", "REDIS_CONNECTION_STRING",
"APPLE_DOWNLOAD_URL", "APPLE_MUSIC_AIO_URL", "SPOTIFY_LYRICS_API_URL",
"DEBUG_LOG_ALL_REQUESTS", "DEBUG_REDACT_SENSITIVE_REQUEST_VALUES", "MUSICBRAINZ_USERNAME",
"MUSICBRAINZ_PASSWORD"
};
+44
View File
@@ -3681,6 +3681,48 @@ class AllstarrApp extends LitElement {
["deployment", "deployment_only", "deployment_checklist"].includes(section.id));
}
migrationOptionalRuntimeServices() {
const preview = this.envMigration.preview || {};
const keys = new Set(asArray(preview.items || preview.Items)
.map((item) => String(item.key || item.Key || "").toUpperCase()));
const services = [];
if (keys.has("SPOTIFY_LYRICS_API_URL")) {
services.push({
id: "spotify-lyrics",
title: "Spotify lyrics sidecar",
text: "The endpoint URL is imported as a runtime setting, but the WebUI does not start containers or give the sidecar your Spotify cookie. Add the optional Compose overlay on the host.",
guide: "https://github.com/SoPat712/allstarr/blob/dev/docs/operations/spotify-lyrics-sidecar.md",
command: "docker compose -f docker-compose.yml -f docker-compose.spotify-lyrics.yml config --quiet\ndocker compose -f docker-compose.yml -f docker-compose.spotify-lyrics.yml up -d\ndocker compose -f docker-compose.yml -f docker-compose.spotify-lyrics.yml ps",
});
}
if (["APPLE_DOWNLOAD_URL", "APPLE_MUSIC_AIO_URL", "APPLE_DOWNLOAD_QUALITY", "APPLE_MUSIC_QUALITY"]
.some((key) => keys.has(key))) {
services.push({
id: "apple-download",
title: "Apple download gateway",
text: "The endpoint URL is imported as a runtime setting. Deploy a compatible gateway separately and verify it under Sources > Apple download. Do not point Allstarr directly at wrapper-v2.",
guide: "https://github.com/SoPat712/allstarr/blob/dev/docs/operations/apple-download-provider.md",
command: "docker compose ps\n# Then open Sources > Apple download and verify the gateway",
});
}
return services;
}
renderMigrationOptionalRuntimeGuidance() {
const services = this.migrationOptionalRuntimeServices();
if (!services.length) return nothing;
return html`<aside class="callout warning" aria-label="Optional service setup required">
<h4>Optional services still need setup</h4>
<p>These endpoints stay outside Allstarr. No URL, login, token, or session value is shown here.</p>
${services.map((service) => html`<section>
<strong>${service.title}</strong>
<p>${service.text}</p>
<p><a href=${service.guide} target="_blank" rel="noopener noreferrer">Open the setup guide</a></p>
<pre><code>${service.command}</code></pre>
</section>`)}
</aside>`;
}
migrationEntryIsSensitive(entry) {
if (entry.sensitive ?? entry.Sensitive ?? entry.isSecret ?? entry.IsSecret) return true;
const key = String(entry.key || entry.Key || entry.sourceKey || entry.SourceKey || "");
@@ -3788,6 +3830,7 @@ class AllstarrApp extends LitElement {
</section>`;
}) : html`<div class="empty">No supported legacy settings were found. Nothing will be changed.</div>`}
<div class="callout"><strong>What confirmation means</strong><p>Only rows marked for durable import are applied automatically. Disabled shared accounts remain disabled, users reconnect personal accounts themselves, deployment-only values stay on the host checklist, and playlists requiring a target or owner remain handoffs.</p></div>
${this.renderMigrationOptionalRuntimeGuidance()}
<form class="env-migration-confirm" @submit=${(event) => this.applyEnvMigration(event)}>
<label class="inline-check">
<input name="confirmMigration" type="checkbox" required ?disabled=${migration.state === "applying"}>
@@ -3808,6 +3851,7 @@ class AllstarrApp extends LitElement {
<p>Imported durable settings are active immediately. They do not require an Allstarr restart.</p>
${hasDeploymentChecklist ? html`<p>Deployment-owned values were not copied to the server. Review the deployment checklist, update Compose or the host <code>.env</code>, then recreate the Allstarr container to apply those separate changes.</p>` : html`<p>No container restart is required for this migration.</p>`}
</div>
${this.renderMigrationOptionalRuntimeGuidance()}
<dl><div><dt>Durable settings</dt><dd>${this.migrationResultCount(result.settingsImported ?? result.SettingsImported ?? result.importedSettings ?? result.ImportedSettings)}</dd></div><div><dt>Disabled accounts created</dt><dd>${this.migrationResultCount(result.providerAccountsCreated ?? result.ProviderAccountsCreated)}</dd></div><div><dt>Skipped</dt><dd>${this.migrationResultCount(result.settingsSkipped ?? result.SettingsSkipped) + this.migrationResultCount(result.providerAccountsSkipped ?? result.ProviderAccountsSkipped)}</dd></div><div><dt>Manual checklist</dt><dd>${this.migrationResultCount(result.manualChecklistItems ?? result.ManualChecklistItems)}</dd></div><div><dt>Playlist handoffs</dt><dd>${this.migrationResultCount(result.playlistHandoffsPending ?? result.PlaylistHandoffsPending)}</dd></div></dl>
${resultSections.map((section) => html`<section class="env-migration-result-section" aria-labelledby=${`migration-result-${section.id}`}>
<h5 id=${`migration-result-${section.id}`}>${section.label}</h5>
+18
View File
@@ -0,0 +1,18 @@
# Optional Spotify lyrics sidecar.
# Usage: docker compose -f docker-compose.yml -f docker-compose.spotify-lyrics.yml up -d
services:
spotify-lyrics:
image: akashrchandran/spotify-lyrics-api@sha256:c95749ad939ba136893f16604e90e7f3f070beb2dea3aaa955646ec08330b4c6
platform: linux/amd64
restart: unless-stopped
environment:
SP_DC: ${SPOTIFY_API_SESSION_COOKIE:-}
networks:
- allstarr
security_opt:
- no-new-privileges:true
allstarr:
environment:
SpotifyApi__LyricsApiUrl: ${SPOTIFY_LYRICS_API_URL:-http://spotify-lyrics:8080}
+51
View File
@@ -0,0 +1,51 @@
# Spotify lyrics sidecar
Spotify lyrics are optional. The normal and AIO installs stay healthy without this service. Add it only when you
have a Spotify web session cookie and want Spotify to participate in the lyrics route.
The sidecar is pinned by digest, stays on Allstarr's private Docker network, and does not publish a host port. Its
upstream image currently runs on `linux/amd64`; ARM hosts need Docker's emulation support.
## Add it
Keep the cookie in the stack's host `.env`:
```dotenv
SPOTIFY_API_SESSION_COOKIE=replace-with-your-sp-dc-cookie
SPOTIFY_LYRICS_API_URL=http://spotify-lyrics:8080
```
Then validate and apply the optional overlay:
```bash
docker compose -f docker-compose.yml -f docker-compose.spotify-lyrics.yml config --quiet
docker compose -f docker-compose.yml -f docker-compose.spotify-lyrics.yml up -d
docker compose -f docker-compose.yml -f docker-compose.spotify-lyrics.yml ps
```
This does not replace Postgres, Valkey, application state, downloads, or kept media. Compose may recreate the
Allstarr container to attach the endpoint setting, but it reuses the same volumes.
If a legacy `.env` import contains `SPOTIFY_LYRICS_API_URL`, Allstarr imports that URL as a durable runtime setting.
The WebUI never starts containers or copies the Spotify cookie into a sidecar. The administrator must still add the
overlay so Docker creates the service and supplies its cookie.
## Update or remove it
Normal updates use the same overlay:
```bash
docker compose -f docker-compose.yml -f docker-compose.spotify-lyrics.yml pull
docker compose -f docker-compose.yml -f docker-compose.spotify-lyrics.yml up -d
```
To remove only the optional service:
```bash
docker compose -f docker-compose.yml -f docker-compose.spotify-lyrics.yml rm -s -f spotify-lyrics
docker compose -f docker-compose.yml up -d
```
Remove or clear the Spotify lyrics URL in Sources if you do not want Allstarr to probe the absent endpoint. Removing
the sidecar never removes music or durable Allstarr data.