diff --git a/README.md b/README.md index 0d54c91..55c82ab 100644 --- a/README.md +++ b/README.md @@ -65,13 +65,13 @@ Allstarr includes a web UI for easy configuration and playlist management, acces - `37i9dQZF1DXcBWIGoYBM5M` (just the ID) - `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI) - `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. ### 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. diff --git a/allstarr.Tests/RuntimeEnvConfigurationTests.cs b/allstarr.Tests/RuntimeEnvConfigurationTests.cs new file mode 100644 index 0000000..0db1154 --- /dev/null +++ b/allstarr.Tests/RuntimeEnvConfigurationTests.cs @@ -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 + { + ["SpotifyImport:MatchingIntervalHours"] = "24" + }); + + RuntimeEnvConfiguration.AddDotEnvOverrides(configuration, _envFilePath); + + Assert.Equal(7, configuration.GetValue("SpotifyImport:MatchingIntervalHours")); + } + + public void Dispose() + { + if (File.Exists(_envFilePath)) + { + File.Delete(_envFilePath); + } + } +} diff --git a/allstarr/AppVersion.cs b/allstarr/AppVersion.cs index bce89d9..ed18df0 100644 --- a/allstarr/AppVersion.cs +++ b/allstarr/AppVersion.cs @@ -9,5 +9,5 @@ public static class AppVersion /// /// Current application version. /// - public const string Version = "1.4.1"; + public const string Version = "1.4.3"; } diff --git a/allstarr/Controllers/ConfigController.cs b/allstarr/Controllers/ConfigController.cs index 43dcf5c..48d6530 100644 --- a/allstarr/Controllers/ConfigController.cs +++ b/allstarr/Controllers/ConfigController.cs @@ -580,7 +580,7 @@ public class ConfigController : ControllerBase return Ok(new { - message = "Configuration updated. Restart container to apply changes.", + message = "Configuration updated. Restart Allstarr to apply changes.", updatedKeys = appliedUpdates, requiresRestart = true, envFilePath = _helperService.GetEnvFilePath() @@ -696,7 +696,7 @@ public class ConfigController : ControllerBase _logger.LogWarning("Docker socket not available at {Path}", socketPath); return StatusCode(503, new { 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); return StatusCode((int)response.StatusCode, new { 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"); return StatusCode(500, new { 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 { 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) diff --git a/allstarr/Program.cs b/allstarr/Program.cs index 7725a8c..9a341ee 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -16,6 +16,7 @@ using Microsoft.Extensions.Http; using System.Net; var builder = WebApplication.CreateBuilder(args); +RuntimeEnvConfiguration.AddDotEnvOverrides(builder.Configuration, builder.Environment, Console.Out); // Discover SquidWTF API and streaming endpoints from uptime feeds. var squidWtfEndpointCatalog = await SquidWtfEndpointDiscovery.DiscoverAsync(); diff --git a/allstarr/Services/Admin/AdminHelperService.cs b/allstarr/Services/Admin/AdminHelperService.cs index 465849f..ba0726d 100644 --- a/allstarr/Services/Admin/AdminHelperService.cs +++ b/allstarr/Services/Admin/AdminHelperService.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using allstarr.Models.Settings; using allstarr.Models.Spotify; +using allstarr.Services.Common; namespace allstarr.Services.Admin; @@ -20,9 +21,7 @@ public class AdminHelperService { _logger = logger; _jellyfinSettings = jellyfinSettings.Value; - _envFilePath = environment.IsDevelopment() - ? Path.Combine(environment.ContentRootPath, "..", ".env") - : "/app/.env"; + _envFilePath = RuntimeEnvConfiguration.ResolveEnvFilePath(environment); } public string GetJellyfinAuthHeader() diff --git a/allstarr/Services/Common/RuntimeEnvConfiguration.cs b/allstarr/Services/Common/RuntimeEnvConfiguration.cs new file mode 100644 index 0000000..218d017 --- /dev/null +++ b/allstarr/Services/Common/RuntimeEnvConfiguration.cs @@ -0,0 +1,235 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +namespace allstarr.Services.Common; + +/// +/// 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. +/// +public static class RuntimeEnvConfiguration +{ + private static readonly IReadOnlyDictionary ExactKeyMappings = + new Dictionary(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 SharedBackendKeyMappings = + new Dictionary(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 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 LoadDotEnvOverrides(string envFilePath) + { + var overrides = new Dictionary(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> MapEnvVarToConfiguration(string envKey, string? envValue) + { + if (string.IsNullOrWhiteSpace(envKey) || IgnoredComposeOnlyKeys.Contains(envKey)) + { + yield break; + } + + if (envKey.Contains("__", StringComparison.Ordinal)) + { + yield return new KeyValuePair(envKey.Replace("__", ":"), envValue); + yield break; + } + + if (SharedBackendKeyMappings.TryGetValue(envKey, out var sharedKeys)) + { + foreach (var sharedKey in sharedKeys) + { + yield return new KeyValuePair(sharedKey, envValue); + } + + yield break; + } + + if (ExactKeyMappings.TryGetValue(envKey, out var configKeys)) + { + foreach (var configKey in configKeys) + { + yield return new KeyValuePair(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; + } +} diff --git a/allstarr/wwwroot/index.html b/allstarr/wwwroot/index.html index c2a3722..b405d6e 100644 --- a/allstarr/wwwroot/index.html +++ b/allstarr/wwwroot/index.html @@ -12,7 +12,7 @@
⚠️ Configuration changed. Restart required to apply changes. - +
@@ -858,7 +858,7 @@

- +
diff --git a/allstarr/wwwroot/js/api.js b/allstarr/wwwroot/js/api.js index 8fbb495..67c0ec4 100644 --- a/allstarr/wwwroot/js/api.js +++ b/allstarr/wwwroot/js/api.js @@ -274,7 +274,7 @@ export async function restartContainer() { return requestJson( "/api/admin/restart", { method: "POST" }, - "Failed to restart container", + "Failed to restart Allstarr", ); } diff --git a/allstarr/wwwroot/js/operations.js b/allstarr/wwwroot/js/operations.js index 3d3f926..9de2407 100644 --- a/allstarr/wwwroot/js/operations.js +++ b/allstarr/wwwroot/js/operations.js @@ -270,7 +270,7 @@ async function importEnv(event) { const result = await runAction({ 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), success: (data) => data.message, error: (err) => err.message || "Failed to import .env file", @@ -283,7 +283,7 @@ async function importEnv(event) { async function restartContainer() { if ( !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; @@ -291,7 +291,7 @@ async function restartContainer() { const result = await runAction({ task: () => API.restartContainer(), - error: "Failed to restart container", + error: "Failed to restart Allstarr", }); if (!result) { @@ -301,7 +301,7 @@ async function restartContainer() { document.getElementById("restart-overlay")?.classList.add("active"); const statusEl = document.getElementById("restart-status"); if (statusEl) { - statusEl.textContent = "Stopping container..."; + statusEl.textContent = "Restarting Allstarr..."; } setTimeout(() => {