From 6abf0e07176da240756775f692400c21129d7c92 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Tue, 3 Feb 2026 14:37:26 -0500 Subject: [PATCH] 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 --- allstarr/Controllers/AdminController.cs | 464 +++++++++ allstarr/Program.cs | 10 + .../Spotify/SpotifyMissingTracksFetcher.cs | 13 + allstarr/wwwroot/index.html | 960 ++++++++++++++++++ 4 files changed, 1447 insertions(+) create mode 100644 allstarr/Controllers/AdminController.cs create mode 100644 allstarr/wwwroot/index.html diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs new file mode 100644 index 0000000..ea38a0c --- /dev/null +++ b/allstarr/Controllers/AdminController.cs @@ -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; + +/// +/// Admin API controller for the web dashboard. +/// Provides endpoints for viewing status, playlists, and modifying configuration. +/// +[ApiController] +[Route("api/admin")] +public class AdminController : ControllerBase +{ + private readonly ILogger _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 logger, + IConfiguration configuration, + IOptions spotifyApiSettings, + IOptions spotifyImportSettings, + IOptions 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; + } + + /// + /// Get current system status and configuration + /// + [HttpGet("status")] + public async Task 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("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 + } + }); + } + + /// + /// Get list of configured playlists with their current data + /// + [HttpGet("playlists")] + public async Task GetPlaylists() + { + var playlists = new List(); + + foreach (var config in _spotifyImportSettings.Playlists) + { + var playlistInfo = new Dictionary + { + ["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 }); + } + + /// + /// Get tracks for a specific playlist + /// + [HttpGet("playlists/{name}/tracks")] + public async Task 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 + }) + }); + } + + /// + /// Trigger a manual refresh of all playlists + /// + [HttpPost("playlists/refresh")] + public async Task RefreshPlaylists() + { + _logger.LogInformation("Manual playlist refresh triggered from admin UI"); + await _playlistFetcher.TriggerFetchAsync(); + return Ok(new { message = "Playlist refresh triggered", timestamp = DateTime.UtcNow }); + } + + /// + /// Get current configuration (safe values only) + /// + [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 + } + }); + } + + /// + /// Update configuration by modifying .env file + /// + [HttpPost("config")] + public async Task 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(); + + 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(); + 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 }); + } + } + + /// + /// Add a new playlist to the configuration + /// + [HttpPost("playlists")] + public async Task 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 + { + ["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson + } + }; + + return await UpdateConfig(updateRequest); + } + + /// + /// Remove a playlist from the configuration + /// + [HttpDelete("playlists/{name}")] + public async Task 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 + { + ["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson + } + }; + + return await UpdateConfig(updateRequest); + } + + /// + /// Clear all cached data + /// + [HttpPost("cache/clear")] + public async Task 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 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"; +} diff --git a/allstarr/Program.cs b/allstarr/Program.cs index 2346f91..4febdde 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -565,6 +565,10 @@ if (app.Environment.IsDevelopment()) app.UseHttpsRedirection(); +// Serve static files from wwwroot +app.UseDefaultFiles(); +app.UseStaticFiles(); + app.UseAuthorization(); app.UseCors(); @@ -574,6 +578,9 @@ app.MapControllers(); // Health check endpoint for monitoring app.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTime.UtcNow })); +// Admin dashboard redirect +app.MapGet("/admin", () => Results.Redirect("/index.html")); + app.Run(); /// @@ -594,6 +601,9 @@ class BackendControllerFeatureProvider : Microsoft.AspNetCore.Mvc.Controllers.Co var isController = base.IsController(typeInfo); if (!isController) return false; + // AdminController should always be registered (for web UI) + if (typeInfo.Name == "AdminController") return true; + // Only register the controller matching the configured backend type return _backendType switch { diff --git a/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs b/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs index 67cc220..08bb4d7 100644 --- a/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs +++ b/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs @@ -10,6 +10,7 @@ namespace allstarr.Services.Spotify; public class SpotifyMissingTracksFetcher : BackgroundService { private readonly IOptions _spotifySettings; + private readonly IOptions _spotifyApiSettings; private readonly IOptions _jellyfinSettings; private readonly IHttpClientFactory _httpClientFactory; private readonly RedisCacheService _cache; @@ -21,6 +22,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService public SpotifyMissingTracksFetcher( IOptions spotifySettings, + IOptions spotifyApiSettings, IOptions jellyfinSettings, IHttpClientFactory httpClientFactory, RedisCacheService cache, @@ -28,6 +30,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService ILogger logger) { _spotifySettings = spotifySettings; + _spotifyApiSettings = spotifyApiSettings; _jellyfinSettings = jellyfinSettings; _httpClientFactory = httpClientFactory; _cache = cache; @@ -52,6 +55,16 @@ public class SpotifyMissingTracksFetcher : BackgroundService // Ensure cache directory exists Directory.CreateDirectory(CacheDirectory); + // Check if SpotifyApi is enabled with a valid session cookie + // If so, SpotifyPlaylistFetcher will handle everything - we don't need to scrape Jellyfin + if (_spotifyApiSettings.Value.Enabled && !string.IsNullOrEmpty(_spotifyApiSettings.Value.SessionCookie)) + { + _logger.LogInformation("SpotifyApi is enabled with session cookie - using direct Spotify API instead of Jellyfin scraping"); + _logger.LogInformation("This service will remain dormant. SpotifyPlaylistFetcher is handling playlists."); + _logger.LogInformation("========================================"); + return; + } + if (!_spotifySettings.Value.Enabled) { _logger.LogInformation("Spotify playlist injection is DISABLED"); diff --git a/allstarr/wwwroot/index.html b/allstarr/wwwroot/index.html new file mode 100644 index 0000000..985dc55 --- /dev/null +++ b/allstarr/wwwroot/index.html @@ -0,0 +1,960 @@ + + + + + + Allstarr Dashboard + + + +
+
+

+ Allstarr v1.0.0 +

+
+ + + Loading... + +
+
+ +
+
Dashboard
+
Playlists
+
Configuration
+
+ + +
+
+
+

Spotify API

+
+ Status + Loading... +
+
+ User + - +
+
+ Cache Duration + - +
+
+ ISRC Matching + - +
+
+ +
+

Jellyfin

+
+ Backend + - +
+
+ URL + - +
+
+ Playlists + - +
+
+
+ +
+

+ Quick Actions +

+
+ + + +
+
+
+ + +
+
+

+ Configured Playlists +
+ + +
+

+ + + + + + + + + + + + + + + + +
NameSpotify IDTracksPositionCache AgeActions
+ Loading playlists... +
+
+
+ + +
+
+

Spotify API Settings

+
+
+ API Enabled + - +
+
+ Client ID + - +
+
+ Session Cookie + - + +
+
+ Cache Duration + - +
+
+ ISRC Matching + - +
+
+
+ +
+

Jellyfin Settings

+
+
+ URL + - +
+
+ API Key + - +
+
+ Library ID + - +
+
+
+ +
+

Danger Zone

+

+ These actions can affect your data. Use with caution. +

+ +
+
+
+ + + + + + + + + + + + +