v1.4.3: fixed .env restarting from Admin UI, re-release of prev ver

This commit is contained in:
2026-03-25 16:11:27 -04:00
parent 5c184d38c8
commit 0a5b383526
10 changed files with 341 additions and 18 deletions
+2 -2
View File
@@ -65,13 +65,13 @@ Allstarr includes a web UI for easy configuration and playlist management, acces
- `37i9dQZF1DXcBWIGoYBM5M` (just the ID) - `37i9dQZF1DXcBWIGoYBM5M` (just the ID)
- `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI) - `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI)
- `https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M` (full URL) - `https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M` (full URL)
4. **Restart** to apply changes (should be a banner) 4. **Restart Allstarr** to apply changes (should be a banner)
Then, proceeed to **Active Playlists**, which shows you which Spotify playlists are currently being monitored and filled with tracks, and lets you do a bunch of useful operations on them. Then, proceeed to **Active Playlists**, which shows you which Spotify playlists are currently being monitored and filled with tracks, and lets you do a bunch of useful operations on them.
### Configuration Persistence ### Configuration Persistence
The web UI updates your `.env` file directly. Changes persist across container restarts, but require a restart to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`. The web UI updates your `.env` file directly. Allstarr reloads that file on startup, so a normal container restart is enough for UI changes to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`.
There's an environment variable to modify this. There's an environment variable to modify this.
@@ -0,0 +1,88 @@
using allstarr.Services.Common;
using Microsoft.Extensions.Configuration;
namespace allstarr.Tests;
public sealed class RuntimeEnvConfigurationTests : IDisposable
{
private readonly string _envFilePath = Path.Combine(
Path.GetTempPath(),
$"allstarr-runtime-{Guid.NewGuid():N}.env");
[Fact]
public void MapEnvVarToConfiguration_MapsFlatKeyToNestedConfigKey()
{
var mappings = RuntimeEnvConfiguration
.MapEnvVarToConfiguration("SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS", "7")
.ToList();
var mapping = Assert.Single(mappings);
Assert.Equal("SpotifyImport:MatchingIntervalHours", mapping.Key);
Assert.Equal("7", mapping.Value);
}
[Fact]
public void MapEnvVarToConfiguration_MapsSharedBackendKeysToBothSections()
{
var mappings = RuntimeEnvConfiguration
.MapEnvVarToConfiguration("MUSIC_SERVICE", "Qobuz")
.OrderBy(x => x.Key, StringComparer.Ordinal)
.ToList();
Assert.Equal(2, mappings.Count);
Assert.Equal("Jellyfin:MusicService", mappings[0].Key);
Assert.Equal("Qobuz", mappings[0].Value);
Assert.Equal("Subsonic:MusicService", mappings[1].Key);
Assert.Equal("Qobuz", mappings[1].Value);
}
[Fact]
public void MapEnvVarToConfiguration_IgnoresComposeOnlyMountKeys()
{
var mappings = RuntimeEnvConfiguration
.MapEnvVarToConfiguration("DOWNLOAD_PATH", "./downloads")
.ToList();
Assert.Empty(mappings);
}
[Fact]
public void LoadDotEnvOverrides_StripsQuotesAndSupportsDoubleUnderscoreKeys()
{
File.WriteAllText(
_envFilePath,
"""
SPOTIFY_API_SESSION_COOKIE="secret-cookie"
Admin__EnableEnvExport=true
""");
var overrides = RuntimeEnvConfiguration.LoadDotEnvOverrides(_envFilePath);
Assert.Equal("secret-cookie", overrides["SpotifyApi:SessionCookie"]);
Assert.Equal("true", overrides["Admin:EnableEnvExport"]);
}
[Fact]
public void AddDotEnvOverrides_OverridesEarlierConfigurationValues()
{
File.WriteAllText(_envFilePath, "SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=7\n");
var configuration = new ConfigurationManager();
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["SpotifyImport:MatchingIntervalHours"] = "24"
});
RuntimeEnvConfiguration.AddDotEnvOverrides(configuration, _envFilePath);
Assert.Equal(7, configuration.GetValue<int>("SpotifyImport:MatchingIntervalHours"));
}
public void Dispose()
{
if (File.Exists(_envFilePath))
{
File.Delete(_envFilePath);
}
}
}
+1 -1
View File
@@ -9,5 +9,5 @@ public static class AppVersion
/// <summary> /// <summary>
/// Current application version. /// Current application version.
/// </summary> /// </summary>
public const string Version = "1.4.1"; public const string Version = "1.4.3";
} }
+5 -5
View File
@@ -580,7 +580,7 @@ public class ConfigController : ControllerBase
return Ok(new return Ok(new
{ {
message = "Configuration updated. Restart container to apply changes.", message = "Configuration updated. Restart Allstarr to apply changes.",
updatedKeys = appliedUpdates, updatedKeys = appliedUpdates,
requiresRestart = true, requiresRestart = true,
envFilePath = _helperService.GetEnvFilePath() envFilePath = _helperService.GetEnvFilePath()
@@ -696,7 +696,7 @@ public class ConfigController : ControllerBase
_logger.LogWarning("Docker socket not available at {Path}", socketPath); _logger.LogWarning("Docker socket not available at {Path}", socketPath);
return StatusCode(503, new { return StatusCode(503, new {
error = "Docker socket not available", error = "Docker socket not available",
message = "Please restart manually: docker-compose restart allstarr" message = "Please restart manually: docker restart allstarr"
}); });
} }
@@ -749,7 +749,7 @@ public class ConfigController : ControllerBase
_logger.LogError("Failed to restart container: {StatusCode} - {Body}", response.StatusCode, errorBody); _logger.LogError("Failed to restart container: {StatusCode} - {Body}", response.StatusCode, errorBody);
return StatusCode((int)response.StatusCode, new { return StatusCode((int)response.StatusCode, new {
error = "Failed to restart container", error = "Failed to restart container",
message = "Please restart manually: docker-compose restart allstarr" message = "Please restart manually: docker restart allstarr"
}); });
} }
} }
@@ -758,7 +758,7 @@ public class ConfigController : ControllerBase
_logger.LogError(ex, "Error restarting container"); _logger.LogError(ex, "Error restarting container");
return StatusCode(500, new { return StatusCode(500, new {
error = "Failed to restart container", error = "Failed to restart container",
message = "Please restart manually: docker-compose restart allstarr" message = "Please restart manually: docker restart allstarr"
}); });
} }
} }
@@ -890,7 +890,7 @@ public class ConfigController : ControllerBase
return Ok(new return Ok(new
{ {
success = true, success = true,
message = ".env file imported successfully. Restart the application for changes to take effect." message = ".env file imported successfully. Restart Allstarr for changes to take effect."
}); });
} }
catch (Exception ex) catch (Exception ex)
+1
View File
@@ -16,6 +16,7 @@ using Microsoft.Extensions.Http;
using System.Net; using System.Net;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
RuntimeEnvConfiguration.AddDotEnvOverrides(builder.Configuration, builder.Environment, Console.Out);
// Discover SquidWTF API and streaming endpoints from uptime feeds. // Discover SquidWTF API and streaming endpoints from uptime feeds.
var squidWtfEndpointCatalog = await SquidWtfEndpointDiscovery.DiscoverAsync(); var squidWtfEndpointCatalog = await SquidWtfEndpointDiscovery.DiscoverAsync();
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using allstarr.Models.Settings; using allstarr.Models.Settings;
using allstarr.Models.Spotify; using allstarr.Models.Spotify;
using allstarr.Services.Common;
namespace allstarr.Services.Admin; namespace allstarr.Services.Admin;
@@ -20,9 +21,7 @@ public class AdminHelperService
{ {
_logger = logger; _logger = logger;
_jellyfinSettings = jellyfinSettings.Value; _jellyfinSettings = jellyfinSettings.Value;
_envFilePath = environment.IsDevelopment() _envFilePath = RuntimeEnvConfiguration.ResolveEnvFilePath(environment);
? Path.Combine(environment.ContentRootPath, "..", ".env")
: "/app/.env";
} }
public string GetJellyfinAuthHeader() public string GetJellyfinAuthHeader()
@@ -0,0 +1,235 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
namespace allstarr.Services.Common;
/// <summary>
/// Loads supported flat .env keys into ASP.NET configuration so Docker/admin UI
/// updates stored in /app/.env take effect on the next application startup.
/// </summary>
public static class RuntimeEnvConfiguration
{
private static readonly IReadOnlyDictionary<string, string[]> ExactKeyMappings =
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["BACKEND_TYPE"] = ["Backend:Type"],
["ADMIN_BIND_ANY_IP"] = ["Admin:BindAnyIp"],
["ADMIN_TRUSTED_SUBNETS"] = ["Admin:TrustedSubnets"],
["ADMIN_ENABLE_ENV_EXPORT"] = ["Admin:EnableEnvExport"],
["CORS_ALLOWED_ORIGINS"] = ["Cors:AllowedOrigins"],
["CORS_ALLOWED_METHODS"] = ["Cors:AllowedMethods"],
["CORS_ALLOWED_HEADERS"] = ["Cors:AllowedHeaders"],
["CORS_ALLOW_CREDENTIALS"] = ["Cors:AllowCredentials"],
["SUBSONIC_URL"] = ["Subsonic:Url"],
["JELLYFIN_URL"] = ["Jellyfin:Url"],
["JELLYFIN_API_KEY"] = ["Jellyfin:ApiKey"],
["JELLYFIN_USER_ID"] = ["Jellyfin:UserId"],
["JELLYFIN_CLIENT_USERNAME"] = ["Jellyfin:ClientUsername"],
["JELLYFIN_LIBRARY_ID"] = ["Jellyfin:LibraryId"],
["LIBRARY_DOWNLOAD_PATH"] = ["Library:DownloadPath"],
["LIBRARY_KEPT_PATH"] = ["Library:KeptPath"],
["REDIS_ENABLED"] = ["Redis:Enabled"],
["REDIS_CONNECTION_STRING"] = ["Redis:ConnectionString"],
["SPOTIFY_IMPORT_ENABLED"] = ["SpotifyImport:Enabled"],
["SPOTIFY_IMPORT_SYNC_START_HOUR"] = ["SpotifyImport:SyncStartHour"],
["SPOTIFY_IMPORT_SYNC_START_MINUTE"] = ["SpotifyImport:SyncStartMinute"],
["SPOTIFY_IMPORT_SYNC_WINDOW_HOURS"] = ["SpotifyImport:SyncWindowHours"],
["SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS"] = ["SpotifyImport:MatchingIntervalHours"],
["SPOTIFY_IMPORT_PLAYLISTS"] = ["SpotifyImport:Playlists"],
["SPOTIFY_IMPORT_PLAYLIST_IDS"] = ["SpotifyImport:PlaylistIds"],
["SPOTIFY_IMPORT_PLAYLIST_NAMES"] = ["SpotifyImport:PlaylistNames"],
["SPOTIFY_IMPORT_PLAYLIST_LOCAL_TRACKS_POSITIONS"] = ["SpotifyImport:PlaylistLocalTracksPositions"],
["SPOTIFY_API_ENABLED"] = ["SpotifyApi:Enabled"],
["SPOTIFY_API_SESSION_COOKIE"] = ["SpotifyApi:SessionCookie"],
["SPOTIFY_API_SESSION_COOKIE_SET_DATE"] = ["SpotifyApi:SessionCookieSetDate"],
["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"],
["SCROBBLING_LASTFM_ENABLED"] = ["Scrobbling:LastFm:Enabled"],
["SCROBBLING_LASTFM_API_KEY"] = ["Scrobbling:LastFm:ApiKey"],
["SCROBBLING_LASTFM_SHARED_SECRET"] = ["Scrobbling:LastFm:SharedSecret"],
["SCROBBLING_LASTFM_SESSION_KEY"] = ["Scrobbling:LastFm:SessionKey"],
["SCROBBLING_LASTFM_USERNAME"] = ["Scrobbling:LastFm:Username"],
["SCROBBLING_LASTFM_PASSWORD"] = ["Scrobbling:LastFm:Password"],
["SCROBBLING_LISTENBRAINZ_ENABLED"] = ["Scrobbling:ListenBrainz:Enabled"],
["SCROBBLING_LISTENBRAINZ_USER_TOKEN"] = ["Scrobbling:ListenBrainz:UserToken"],
["DEBUG_LOG_ALL_REQUESTS"] = ["Debug:LogAllRequests"],
["DEBUG_REDACT_SENSITIVE_REQUEST_VALUES"] = ["Debug:RedactSensitiveRequestValues"],
["DEEZER_ARL"] = ["Deezer:Arl"],
["DEEZER_ARL_FALLBACK"] = ["Deezer:ArlFallback"],
["DEEZER_QUALITY"] = ["Deezer:Quality"],
["DEEZER_MIN_REQUEST_INTERVAL_MS"] = ["Deezer:MinRequestIntervalMs"],
["QOBUZ_USER_AUTH_TOKEN"] = ["Qobuz:UserAuthToken"],
["QOBUZ_USER_ID"] = ["Qobuz:UserId"],
["QOBUZ_QUALITY"] = ["Qobuz:Quality"],
["QOBUZ_MIN_REQUEST_INTERVAL_MS"] = ["Qobuz:MinRequestIntervalMs"],
["SQUIDWTF_QUALITY"] = ["SquidWTF:Quality"],
["SQUIDWTF_MIN_REQUEST_INTERVAL_MS"] = ["SquidWTF:MinRequestIntervalMs"],
["MUSICBRAINZ_ENABLED"] = ["MusicBrainz:Enabled"],
["MUSICBRAINZ_USERNAME"] = ["MusicBrainz:Username"],
["MUSICBRAINZ_PASSWORD"] = ["MusicBrainz:Password"],
["CACHE_SEARCH_RESULTS_MINUTES"] = ["Cache:SearchResultsMinutes"],
["CACHE_PLAYLIST_IMAGES_HOURS"] = ["Cache:PlaylistImagesHours"],
["CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS"] = ["Cache:SpotifyPlaylistItemsHours"],
["CACHE_SPOTIFY_MATCHED_TRACKS_DAYS"] = ["Cache:SpotifyMatchedTracksDays"],
["CACHE_LYRICS_DAYS"] = ["Cache:LyricsDays"],
["CACHE_GENRE_DAYS"] = ["Cache:GenreDays"],
["CACHE_METADATA_DAYS"] = ["Cache:MetadataDays"],
["CACHE_ODESLI_LOOKUP_DAYS"] = ["Cache:OdesliLookupDays"],
["CACHE_PROXY_IMAGES_DAYS"] = ["Cache:ProxyImagesDays"],
["CACHE_TRANSCODE_MINUTES"] = ["Cache:TranscodeCacheMinutes"]
};
private static readonly IReadOnlyDictionary<string, string[]> SharedBackendKeyMappings =
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["MUSIC_SERVICE"] = ["Subsonic:MusicService", "Jellyfin:MusicService"],
["EXPLICIT_FILTER"] = ["Subsonic:ExplicitFilter", "Jellyfin:ExplicitFilter"],
["DOWNLOAD_MODE"] = ["Subsonic:DownloadMode", "Jellyfin:DownloadMode"],
["STORAGE_MODE"] = ["Subsonic:StorageMode", "Jellyfin:StorageMode"],
["CACHE_DURATION_HOURS"] = ["Subsonic:CacheDurationHours", "Jellyfin:CacheDurationHours"],
["ENABLE_EXTERNAL_PLAYLISTS"] = ["Subsonic:EnableExternalPlaylists", "Jellyfin:EnableExternalPlaylists"],
["PLAYLISTS_DIRECTORY"] = ["Subsonic:PlaylistsDirectory", "Jellyfin:PlaylistsDirectory"]
};
private static readonly HashSet<string> IgnoredComposeOnlyKeys = new(StringComparer.OrdinalIgnoreCase)
{
"DOWNLOAD_PATH",
"KEPT_PATH",
"CACHE_PATH",
"REDIS_DATA_PATH"
};
public static string ResolveEnvFilePath(IHostEnvironment environment)
{
return environment.IsDevelopment()
? Path.GetFullPath(Path.Combine(environment.ContentRootPath, "..", ".env"))
: "/app/.env";
}
public static void AddDotEnvOverrides(
ConfigurationManager configuration,
IHostEnvironment environment,
TextWriter? logWriter = null)
{
AddDotEnvOverrides(configuration, ResolveEnvFilePath(environment), logWriter);
}
public static void AddDotEnvOverrides(
ConfigurationManager configuration,
string envFilePath,
TextWriter? logWriter = null)
{
var overrides = LoadDotEnvOverrides(envFilePath);
if (overrides.Count == 0)
{
if (File.Exists(envFilePath))
{
logWriter?.WriteLine($"No supported runtime overrides found in {envFilePath}");
}
return;
}
configuration.AddInMemoryCollection(overrides);
logWriter?.WriteLine($"Loaded {overrides.Count} runtime override(s) from {envFilePath}");
}
public static Dictionary<string, string?> LoadDotEnvOverrides(string envFilePath)
{
var overrides = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
if (!File.Exists(envFilePath))
{
return overrides;
}
foreach (var line in File.ReadLines(envFilePath))
{
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#'))
{
continue;
}
var separatorIndex = line.IndexOf('=');
if (separatorIndex <= 0)
{
continue;
}
var envKey = line[..separatorIndex].Trim();
var envValue = StripQuotes(line[(separatorIndex + 1)..].Trim());
foreach (var mapping in MapEnvVarToConfiguration(envKey, envValue))
{
overrides[mapping.Key] = mapping.Value;
}
}
return overrides;
}
public static IEnumerable<KeyValuePair<string, string?>> MapEnvVarToConfiguration(string envKey, string? envValue)
{
if (string.IsNullOrWhiteSpace(envKey) || IgnoredComposeOnlyKeys.Contains(envKey))
{
yield break;
}
if (envKey.Contains("__", StringComparison.Ordinal))
{
yield return new KeyValuePair<string, string?>(envKey.Replace("__", ":"), envValue);
yield break;
}
if (SharedBackendKeyMappings.TryGetValue(envKey, out var sharedKeys))
{
foreach (var sharedKey in sharedKeys)
{
yield return new KeyValuePair<string, string?>(sharedKey, envValue);
}
yield break;
}
if (ExactKeyMappings.TryGetValue(envKey, out var configKeys))
{
foreach (var configKey in configKeys)
{
yield return new KeyValuePair<string, string?>(configKey, envValue);
}
}
}
private static string StripQuotes(string? value)
{
if (string.IsNullOrEmpty(value))
{
return value ?? string.Empty;
}
if (value.StartsWith('"') && value.EndsWith('"') && value.Length >= 2)
{
return value[1..^1];
}
return value;
}
}
+2 -2
View File
@@ -12,7 +12,7 @@
<!-- Restart Required Banner --> <!-- Restart Required Banner -->
<div class="restart-banner" id="restart-banner"> <div class="restart-banner" id="restart-banner">
⚠️ Configuration changed. Restart required to apply changes. ⚠️ Configuration changed. Restart required to apply changes.
<button onclick="restartContainer()">Restart Now</button> <button onclick="restartContainer()">Restart Allstarr</button>
<button onclick="dismissRestartBanner()" <button onclick="dismissRestartBanner()"
style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button> style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button>
</div> </div>
@@ -858,7 +858,7 @@
</p> </p>
<div style="display: flex; gap: 12px; flex-wrap: wrap;"> <div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button class="danger" onclick="clearCache()">Clear All Cache</button> <button class="danger" onclick="clearCache()">Clear All Cache</button>
<button class="danger" onclick="restartContainer()">Restart Container</button> <button class="danger" onclick="restartContainer()">Restart Allstarr</button>
</div> </div>
</div> </div>
</div> </div>
+1 -1
View File
@@ -274,7 +274,7 @@ export async function restartContainer() {
return requestJson( return requestJson(
"/api/admin/restart", "/api/admin/restart",
{ method: "POST" }, { method: "POST" },
"Failed to restart container", "Failed to restart Allstarr",
); );
} }
+4 -4
View File
@@ -270,7 +270,7 @@ async function importEnv(event) {
const result = await runAction({ const result = await runAction({
confirmMessage: confirmMessage:
"Import this .env file? This will replace your current configuration.\n\nA backup will be created automatically.\n\nYou will need to restart the container for changes to take effect.", "Import this .env file? This will replace your current configuration.\n\nA backup will be created automatically.\n\nYou will need to restart Allstarr for changes to take effect.",
task: () => API.importEnv(file), task: () => API.importEnv(file),
success: (data) => data.message, success: (data) => data.message,
error: (err) => err.message || "Failed to import .env file", error: (err) => err.message || "Failed to import .env file",
@@ -283,7 +283,7 @@ async function importEnv(event) {
async function restartContainer() { async function restartContainer() {
if ( if (
!confirm( !confirm(
"Restart the container to apply configuration changes?\n\nThe dashboard will be temporarily unavailable.", "Restart Allstarr to reload /app/.env and apply configuration changes?\n\nThe dashboard will be temporarily unavailable.",
) )
) { ) {
return; return;
@@ -291,7 +291,7 @@ async function restartContainer() {
const result = await runAction({ const result = await runAction({
task: () => API.restartContainer(), task: () => API.restartContainer(),
error: "Failed to restart container", error: "Failed to restart Allstarr",
}); });
if (!result) { if (!result) {
@@ -301,7 +301,7 @@ async function restartContainer() {
document.getElementById("restart-overlay")?.classList.add("active"); document.getElementById("restart-overlay")?.classList.add("active");
const statusEl = document.getElementById("restart-status"); const statusEl = document.getElementById("restart-status");
if (statusEl) { if (statusEl) {
statusEl.textContent = "Stopping container..."; statusEl.textContent = "Restarting Allstarr...";
} }
setTimeout(() => { setTimeout(() => {