Add web dashboard UI for configuration and playlist management

- Add AdminController with API endpoints for status, playlists, config
- Add web UI dashboard at /admin with dark theme
- Disable SpotifyMissingTracksFetcher when SpotifyApi is enabled with cookie
- Support viewing playlists, tracks, and managing configuration
- Add .env file modification from UI (requires restart to apply)
- Dashboard auto-refreshes status every 30 seconds
This commit is contained in:
2026-02-03 14:37:26 -05:00
parent 8e7fc8b4ef
commit 6abf0e0717
4 changed files with 1447 additions and 0 deletions

View File

@@ -0,0 +1,464 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Services.Spotify;
using allstarr.Services.Common;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace allstarr.Controllers;
/// <summary>
/// Admin API controller for the web dashboard.
/// Provides endpoints for viewing status, playlists, and modifying configuration.
/// </summary>
[ApiController]
[Route("api/admin")]
public class AdminController : ControllerBase
{
private readonly ILogger<AdminController> _logger;
private readonly IConfiguration _configuration;
private readonly SpotifyApiSettings _spotifyApiSettings;
private readonly SpotifyImportSettings _spotifyImportSettings;
private readonly JellyfinSettings _jellyfinSettings;
private readonly SpotifyApiClient _spotifyClient;
private readonly SpotifyPlaylistFetcher _playlistFetcher;
private readonly RedisCacheService _cache;
private const string EnvFilePath = "/app/.env";
private const string CacheDirectory = "/app/cache/spotify";
public AdminController(
ILogger<AdminController> logger,
IConfiguration configuration,
IOptions<SpotifyApiSettings> spotifyApiSettings,
IOptions<SpotifyImportSettings> spotifyImportSettings,
IOptions<JellyfinSettings> jellyfinSettings,
SpotifyApiClient spotifyClient,
SpotifyPlaylistFetcher playlistFetcher,
RedisCacheService cache)
{
_logger = logger;
_configuration = configuration;
_spotifyApiSettings = spotifyApiSettings.Value;
_spotifyImportSettings = spotifyImportSettings.Value;
_jellyfinSettings = jellyfinSettings.Value;
_spotifyClient = spotifyClient;
_playlistFetcher = playlistFetcher;
_cache = cache;
}
/// <summary>
/// Get current system status and configuration
/// </summary>
[HttpGet("status")]
public async Task<IActionResult> GetStatus()
{
var spotifyAuthStatus = "not_configured";
string? spotifyUser = null;
if (_spotifyApiSettings.Enabled && !string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
{
try
{
var (success, userId, displayName) = await _spotifyClient.GetCurrentUserAsync();
if (success)
{
spotifyAuthStatus = "authenticated";
spotifyUser = displayName ?? userId;
}
else
{
spotifyAuthStatus = "invalid_cookie";
}
}
catch
{
spotifyAuthStatus = "error";
}
}
else if (_spotifyApiSettings.Enabled)
{
spotifyAuthStatus = "missing_cookie";
}
return Ok(new
{
version = "1.0.0",
backendType = _configuration.GetValue<string>("Backend:Type") ?? "Jellyfin",
jellyfinUrl = _jellyfinSettings.Url,
spotify = new
{
apiEnabled = _spotifyApiSettings.Enabled,
authStatus = spotifyAuthStatus,
user = spotifyUser,
hasCookie = !string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie),
cacheDurationMinutes = _spotifyApiSettings.CacheDurationMinutes,
preferIsrcMatching = _spotifyApiSettings.PreferIsrcMatching
},
spotifyImport = new
{
enabled = _spotifyImportSettings.Enabled,
syncTime = $"{_spotifyImportSettings.SyncStartHour:D2}:{_spotifyImportSettings.SyncStartMinute:D2}",
syncWindowHours = _spotifyImportSettings.SyncWindowHours,
playlistCount = _spotifyImportSettings.Playlists.Count
}
});
}
/// <summary>
/// Get list of configured playlists with their current data
/// </summary>
[HttpGet("playlists")]
public async Task<IActionResult> GetPlaylists()
{
var playlists = new List<object>();
foreach (var config in _spotifyImportSettings.Playlists)
{
var playlistInfo = new Dictionary<string, object?>
{
["name"] = config.Name,
["id"] = config.Id,
["localTracksPosition"] = config.LocalTracksPosition.ToString(),
["trackCount"] = 0,
["lastFetched"] = null as DateTime?,
["cacheAge"] = null as string
};
// Try to get cached playlist data
var cacheFilePath = Path.Combine(CacheDirectory, $"{SanitizeFileName(config.Name)}_spotify.json");
if (System.IO.File.Exists(cacheFilePath))
{
try
{
var json = await System.IO.File.ReadAllTextAsync(cacheFilePath);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.TryGetProperty("tracks", out var tracks))
{
playlistInfo["trackCount"] = tracks.GetArrayLength();
}
if (root.TryGetProperty("fetchedAt", out var fetchedAt))
{
var fetchedTime = fetchedAt.GetDateTime();
playlistInfo["lastFetched"] = fetchedTime;
var age = DateTime.UtcNow - fetchedTime;
playlistInfo["cacheAge"] = age.TotalHours < 1
? $"{age.TotalMinutes:F0}m"
: $"{age.TotalHours:F1}h";
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read cache for playlist {Name}", config.Name);
}
}
playlists.Add(playlistInfo);
}
return Ok(new { playlists });
}
/// <summary>
/// Get tracks for a specific playlist
/// </summary>
[HttpGet("playlists/{name}/tracks")]
public async Task<IActionResult> GetPlaylistTracks(string name)
{
var decodedName = Uri.UnescapeDataString(name);
var tracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName);
return Ok(new
{
name = decodedName,
trackCount = tracks.Count,
tracks = tracks.Select(t => new
{
position = t.Position,
title = t.Title,
artists = t.Artists,
album = t.Album,
isrc = t.Isrc,
spotifyId = t.SpotifyId,
durationMs = t.DurationMs,
albumArtUrl = t.AlbumArtUrl
})
});
}
/// <summary>
/// Trigger a manual refresh of all playlists
/// </summary>
[HttpPost("playlists/refresh")]
public async Task<IActionResult> RefreshPlaylists()
{
_logger.LogInformation("Manual playlist refresh triggered from admin UI");
await _playlistFetcher.TriggerFetchAsync();
return Ok(new { message = "Playlist refresh triggered", timestamp = DateTime.UtcNow });
}
/// <summary>
/// Get current configuration (safe values only)
/// </summary>
[HttpGet("config")]
public IActionResult GetConfig()
{
return Ok(new
{
spotifyApi = new
{
enabled = _spotifyApiSettings.Enabled,
clientId = MaskValue(_spotifyApiSettings.ClientId),
sessionCookie = MaskValue(_spotifyApiSettings.SessionCookie, showLast: 8),
cacheDurationMinutes = _spotifyApiSettings.CacheDurationMinutes,
rateLimitDelayMs = _spotifyApiSettings.RateLimitDelayMs,
preferIsrcMatching = _spotifyApiSettings.PreferIsrcMatching
},
spotifyImport = new
{
enabled = _spotifyImportSettings.Enabled,
syncStartHour = _spotifyImportSettings.SyncStartHour,
syncStartMinute = _spotifyImportSettings.SyncStartMinute,
syncWindowHours = _spotifyImportSettings.SyncWindowHours,
playlists = _spotifyImportSettings.Playlists.Select(p => new
{
name = p.Name,
id = p.Id,
localTracksPosition = p.LocalTracksPosition.ToString()
})
},
jellyfin = new
{
url = _jellyfinSettings.Url,
apiKey = MaskValue(_jellyfinSettings.ApiKey),
libraryId = _jellyfinSettings.LibraryId
}
});
}
/// <summary>
/// Update configuration by modifying .env file
/// </summary>
[HttpPost("config")]
public async Task<IActionResult> UpdateConfig([FromBody] ConfigUpdateRequest request)
{
if (request == null || request.Updates == null || request.Updates.Count == 0)
{
return BadRequest(new { error = "No updates provided" });
}
_logger.LogInformation("Config update requested: {Count} changes", request.Updates.Count);
try
{
// Read current .env file or create new one
var envContent = new Dictionary<string, string>();
if (System.IO.File.Exists(EnvFilePath))
{
var lines = await System.IO.File.ReadAllLinesAsync(EnvFilePath);
foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
continue;
var eqIndex = line.IndexOf('=');
if (eqIndex > 0)
{
var key = line[..eqIndex].Trim();
var value = line[(eqIndex + 1)..].Trim();
envContent[key] = value;
}
}
}
// Apply updates with validation
var appliedUpdates = new List<string>();
foreach (var (key, value) in request.Updates)
{
// Validate key format
if (!IsValidEnvKey(key))
{
return BadRequest(new { error = $"Invalid environment variable key: {key}" });
}
envContent[key] = value;
appliedUpdates.Add(key);
_logger.LogInformation(" Setting {Key}", key);
}
// 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");
_logger.LogInformation("Config file updated successfully");
return Ok(new
{
message = "Configuration updated. Restart container to apply changes.",
updatedKeys = appliedUpdates,
requiresRestart = true
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update configuration");
return StatusCode(500, new { error = "Failed to update configuration", details = ex.Message });
}
}
/// <summary>
/// Add a new playlist to the configuration
/// </summary>
[HttpPost("playlists")]
public async Task<IActionResult> AddPlaylist([FromBody] AddPlaylistRequest request)
{
if (string.IsNullOrEmpty(request.Name) || string.IsNullOrEmpty(request.SpotifyId))
{
return BadRequest(new { error = "Name and SpotifyId are required" });
}
_logger.LogInformation("Adding playlist: {Name} ({SpotifyId})", request.Name, request.SpotifyId);
// Get current playlists
var currentPlaylists = _spotifyImportSettings.Playlists.ToList();
// Check for duplicates
if (currentPlaylists.Any(p => p.Id == request.SpotifyId || p.Name == request.Name))
{
return BadRequest(new { error = "Playlist with this name or ID already exists" });
}
// Add new playlist
currentPlaylists.Add(new SpotifyPlaylistConfig
{
Name = request.Name,
Id = request.SpotifyId,
LocalTracksPosition = request.LocalTracksPosition == "last"
? LocalTracksPosition.Last
: LocalTracksPosition.First
});
// Convert to JSON format for env var
var playlistsJson = JsonSerializer.Serialize(
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.LocalTracksPosition.ToString().ToLower() }).ToArray()
);
// Update .env file
var updateRequest = new ConfigUpdateRequest
{
Updates = new Dictionary<string, string>
{
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
}
};
return await UpdateConfig(updateRequest);
}
/// <summary>
/// Remove a playlist from the configuration
/// </summary>
[HttpDelete("playlists/{name}")]
public async Task<IActionResult> RemovePlaylist(string name)
{
var decodedName = Uri.UnescapeDataString(name);
_logger.LogInformation("Removing playlist: {Name}", decodedName);
var currentPlaylists = _spotifyImportSettings.Playlists.ToList();
var playlist = currentPlaylists.FirstOrDefault(p => p.Name == decodedName);
if (playlist == null)
{
return NotFound(new { error = "Playlist not found" });
}
currentPlaylists.Remove(playlist);
// Convert to JSON format for env var
var playlistsJson = JsonSerializer.Serialize(
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.LocalTracksPosition.ToString().ToLower() }).ToArray()
);
// Update .env file
var updateRequest = new ConfigUpdateRequest
{
Updates = new Dictionary<string, string>
{
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
}
};
return await UpdateConfig(updateRequest);
}
/// <summary>
/// Clear all cached data
/// </summary>
[HttpPost("cache/clear")]
public async Task<IActionResult> ClearCache()
{
_logger.LogInformation("Cache clear requested from admin UI");
var clearedFiles = 0;
// Clear file cache
if (Directory.Exists(CacheDirectory))
{
foreach (var file in Directory.GetFiles(CacheDirectory, "*.json"))
{
try
{
System.IO.File.Delete(file);
clearedFiles++;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete cache file {File}", file);
}
}
}
// Clear Redis cache for spotify playlists
foreach (var playlist in _spotifyImportSettings.Playlists)
{
await _cache.DeleteAsync($"spotify:playlist:{playlist.Name}");
await _cache.DeleteAsync($"spotify:missing:{playlist.Name}");
}
return Ok(new { message = "Cache cleared", filesDeleted = clearedFiles });
}
private static string MaskValue(string? value, int showLast = 0)
{
if (string.IsNullOrEmpty(value)) return "(not set)";
if (value.Length <= showLast) return "***";
return showLast > 0 ? "***" + value[^showLast..] : value[..8] + "...";
}
private static string SanitizeFileName(string name)
{
return string.Join("_", name.Split(Path.GetInvalidFileNameChars()));
}
private static bool IsValidEnvKey(string key)
{
// Only allow alphanumeric, underscore, and must start with letter/underscore
return Regex.IsMatch(key, @"^[A-Z_][A-Z0-9_]*$", RegexOptions.IgnoreCase);
}
}
public class ConfigUpdateRequest
{
public Dictionary<string, string> Updates { get; set; } = new();
}
public class AddPlaylistRequest
{
public string Name { get; set; } = string.Empty;
public string SpotifyId { get; set; } = string.Empty;
public string LocalTracksPosition { get; set; } = "first";
}