Fix web UI config persistence and cookie age tracking

- Add SPOTIFY_API_SESSION_COOKIE_SET_DATE to docker-compose.yml env mapping
- Mount .env file in container for web UI to update
- Add SessionCookieSetDate loading in Program.cs
- Improve .env update logic with better error handling and logging
- Auto-initialize cookie date when cookie exists but date not set
- Simplify local vs external track detection in Jellyfin playlists
- Enhanced Spotify playlist ID parsing (supports ID, URI, and URL formats)
- Better UI clarity: renamed tabs to 'Link Playlists' and 'Active Playlists'
This commit is contained in:
2026-02-03 16:50:13 -05:00
parent 79a9e4063d
commit 5606706dc8
7 changed files with 202 additions and 99 deletions

View File

@@ -32,12 +32,14 @@ public class AdminController : ControllerBase
private readonly SpotifyPlaylistFetcher _playlistFetcher;
private readonly RedisCacheService _cache;
private readonly HttpClient _jellyfinHttpClient;
private const string EnvFilePath = "/app/.env";
private readonly IWebHostEnvironment _environment;
private readonly string _envFilePath;
private const string CacheDirectory = "/app/cache/spotify";
public AdminController(
ILogger<AdminController> logger,
IConfiguration configuration,
IWebHostEnvironment environment,
IOptions<SpotifyApiSettings> spotifyApiSettings,
IOptions<SpotifyImportSettings> spotifyImportSettings,
IOptions<JellyfinSettings> jellyfinSettings,
@@ -51,6 +53,7 @@ public class AdminController : ControllerBase
{
_logger = logger;
_configuration = configuration;
_environment = environment;
_spotifyApiSettings = spotifyApiSettings.Value;
_spotifyImportSettings = spotifyImportSettings.Value;
_jellyfinSettings = jellyfinSettings.Value;
@@ -61,6 +64,14 @@ public class AdminController : ControllerBase
_playlistFetcher = playlistFetcher;
_cache = cache;
_jellyfinHttpClient = httpClientFactory.CreateClient();
// .env file path is always /app/.env in Docker (mounted from host)
// In development, it's in the parent directory of ContentRootPath
_envFilePath = _environment.IsDevelopment()
? Path.Combine(_environment.ContentRootPath, "..", ".env")
: "/app/.env";
_logger.LogInformation("Admin controller initialized. .env path: {EnvFilePath}", _envFilePath);
}
/// <summary>
@@ -290,15 +301,21 @@ public class AdminController : ControllerBase
try
{
// Check if .env file exists
if (!System.IO.File.Exists(_envFilePath))
{
_logger.LogWarning(".env file not found at {Path}, creating new file", _envFilePath);
}
// Read current .env file or create new one
var envContent = new Dictionary<string, string>();
if (System.IO.File.Exists(EnvFilePath))
if (System.IO.File.Exists(_envFilePath))
{
var lines = await System.IO.File.ReadAllLinesAsync(EnvFilePath);
var lines = await System.IO.File.ReadAllLinesAsync(_envFilePath);
foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#'))
continue;
var eqIndex = line.IndexOf('=');
@@ -309,6 +326,7 @@ public class AdminController : ControllerBase
envContent[key] = value;
}
}
_logger.LogInformation("Loaded {Count} existing env vars from {Path}", envContent.Count, _envFilePath);
}
// Apply updates with validation
@@ -318,12 +336,16 @@ public class AdminController : ControllerBase
// Validate key format
if (!IsValidEnvKey(key))
{
_logger.LogWarning("Invalid env key rejected: {Key}", key);
return BadRequest(new { error = $"Invalid environment variable key: {key}" });
}
envContent[key] = value;
appliedUpdates.Add(key);
_logger.LogInformation(" Setting {Key}", key);
_logger.LogInformation(" Setting {Key} = {Value}", key,
key.Contains("COOKIE") || key.Contains("TOKEN") || key.Contains("KEY") || key.Contains("ARL")
? "***" + (value.Length > 8 ? value[^8..] : "")
: value);
// Auto-set cookie date when Spotify session cookie is updated
if (key == "SPOTIFY_API_SESSION_COOKIE" && !string.IsNullOrEmpty(value))
@@ -338,21 +360,35 @@ public class AdminController : ControllerBase
// Write back to .env file
var newContent = string.Join("\n", envContent.Select(kv => $"{kv.Key}={kv.Value}"));
await System.IO.File.WriteAllTextAsync(EnvFilePath, newContent + "\n");
await System.IO.File.WriteAllTextAsync(_envFilePath, newContent + "\n");
_logger.LogInformation("Config file updated successfully");
_logger.LogInformation("Config file updated successfully at {Path}", _envFilePath);
return Ok(new
{
message = "Configuration updated. Restart container to apply changes.",
updatedKeys = appliedUpdates,
requiresRestart = true
requiresRestart = true,
envFilePath = _envFilePath
});
}
catch (UnauthorizedAccessException ex)
{
_logger.LogError(ex, "Permission denied writing to .env file at {Path}", _envFilePath);
return StatusCode(500, new {
error = "Permission denied",
details = "Cannot write to .env file. Check file permissions and volume mount.",
path = _envFilePath
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update configuration");
return StatusCode(500, new { error = "Failed to update configuration", details = ex.Message });
_logger.LogError(ex, "Failed to update configuration at {Path}", _envFilePath);
return StatusCode(500, new {
error = "Failed to update configuration",
details = ex.Message,
path = _envFilePath
});
}
}
@@ -793,6 +829,7 @@ public class AdminController : ControllerBase
var response = await _jellyfinHttpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Failed to fetch playlist items for {PlaylistId}: {StatusCode}", playlistId, response.StatusCode);
return (0, 0, 0);
}
@@ -807,62 +844,48 @@ public class AdminController : ControllerBase
{
foreach (var item in items.EnumerateArray())
{
// Check if track has a local path (is in user's library)
var hasPath = item.TryGetProperty("Path", out var path) &&
path.ValueKind == JsonValueKind.String &&
!string.IsNullOrEmpty(path.GetString());
// Simpler detection: Check if Path exists and is not empty
// External tracks from allstarr won't have a Path property
var hasPath = item.TryGetProperty("Path", out var pathProp) &&
pathProp.ValueKind == JsonValueKind.String &&
!string.IsNullOrEmpty(pathProp.GetString());
// Check MediaSources to see if it's a local file vs external
var isLocal = false;
if (item.TryGetProperty("MediaSources", out var mediaSources) &&
mediaSources.ValueKind == JsonValueKind.Array)
if (hasPath)
{
foreach (var source in mediaSources.EnumerateArray())
var pathStr = pathProp.GetString()!;
// Check if it's a real file path (not a URL)
if (pathStr.StartsWith("/") || pathStr.Contains(":\\"))
{
if (source.TryGetProperty("Protocol", out var protocol))
{
var protocolStr = protocol.GetString();
if (protocolStr == "File")
{
isLocal = true;
break;
}
}
// Also check if Path exists in MediaSource
if (source.TryGetProperty("Path", out var sourcePath) &&
sourcePath.ValueKind == JsonValueKind.String &&
!string.IsNullOrEmpty(sourcePath.GetString()))
{
isLocal = true;
break;
}
localTracks++;
}
else
{
// It's a URL or external source
externalTracks++;
externalAvailable++;
}
}
// Fallback to checking Path property
if (!isLocal && hasPath)
{
isLocal = true;
}
if (isLocal)
{
localTracks++;
}
else
{
// No path means it's external
externalTracks++;
// For now, if it's in the playlist but not local, it means a provider found it
externalAvailable++;
}
}
_logger.LogDebug("Playlist {PlaylistId} stats: {Local} local, {External} external",
playlistId, localTracks, externalTracks);
}
else
{
_logger.LogWarning("No Items property in playlist response for {PlaylistId}", playlistId);
}
return (localTracks, externalTracks, externalAvailable);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get track stats for playlist {PlaylistId}", playlistId);
_logger.LogError(ex, "Failed to get track stats for playlist {PlaylistId}", playlistId);
return (0, 0, 0);
}
}