mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
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:
464
allstarr/Controllers/AdminController.cs
Normal file
464
allstarr/Controllers/AdminController.cs
Normal 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";
|
||||||
|
}
|
||||||
@@ -565,6 +565,10 @@ if (app.Environment.IsDevelopment())
|
|||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
// Serve static files from wwwroot
|
||||||
|
app.UseDefaultFiles();
|
||||||
|
app.UseStaticFiles();
|
||||||
|
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.UseCors();
|
app.UseCors();
|
||||||
@@ -574,6 +578,9 @@ app.MapControllers();
|
|||||||
// Health check endpoint for monitoring
|
// Health check endpoint for monitoring
|
||||||
app.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTime.UtcNow }));
|
app.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTime.UtcNow }));
|
||||||
|
|
||||||
|
// Admin dashboard redirect
|
||||||
|
app.MapGet("/admin", () => Results.Redirect("/index.html"));
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -594,6 +601,9 @@ class BackendControllerFeatureProvider : Microsoft.AspNetCore.Mvc.Controllers.Co
|
|||||||
var isController = base.IsController(typeInfo);
|
var isController = base.IsController(typeInfo);
|
||||||
if (!isController) return false;
|
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
|
// Only register the controller matching the configured backend type
|
||||||
return _backendType switch
|
return _backendType switch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ namespace allstarr.Services.Spotify;
|
|||||||
public class SpotifyMissingTracksFetcher : BackgroundService
|
public class SpotifyMissingTracksFetcher : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly IOptions<SpotifyImportSettings> _spotifySettings;
|
private readonly IOptions<SpotifyImportSettings> _spotifySettings;
|
||||||
|
private readonly IOptions<SpotifyApiSettings> _spotifyApiSettings;
|
||||||
private readonly IOptions<JellyfinSettings> _jellyfinSettings;
|
private readonly IOptions<JellyfinSettings> _jellyfinSettings;
|
||||||
private readonly IHttpClientFactory _httpClientFactory;
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
@@ -21,6 +22,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
|
|
||||||
public SpotifyMissingTracksFetcher(
|
public SpotifyMissingTracksFetcher(
|
||||||
IOptions<SpotifyImportSettings> spotifySettings,
|
IOptions<SpotifyImportSettings> spotifySettings,
|
||||||
|
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||||
IOptions<JellyfinSettings> jellyfinSettings,
|
IOptions<JellyfinSettings> jellyfinSettings,
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
RedisCacheService cache,
|
RedisCacheService cache,
|
||||||
@@ -28,6 +30,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
ILogger<SpotifyMissingTracksFetcher> logger)
|
ILogger<SpotifyMissingTracksFetcher> logger)
|
||||||
{
|
{
|
||||||
_spotifySettings = spotifySettings;
|
_spotifySettings = spotifySettings;
|
||||||
|
_spotifyApiSettings = spotifyApiSettings;
|
||||||
_jellyfinSettings = jellyfinSettings;
|
_jellyfinSettings = jellyfinSettings;
|
||||||
_httpClientFactory = httpClientFactory;
|
_httpClientFactory = httpClientFactory;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
@@ -52,6 +55,16 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
// Ensure cache directory exists
|
// Ensure cache directory exists
|
||||||
Directory.CreateDirectory(CacheDirectory);
|
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)
|
if (!_spotifySettings.Value.Enabled)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Spotify playlist injection is DISABLED");
|
_logger.LogInformation("Spotify playlist injection is DISABLED");
|
||||||
|
|||||||
960
allstarr/wwwroot/index.html
Normal file
960
allstarr/wwwroot/index.html
Normal file
@@ -0,0 +1,960 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Allstarr Dashboard</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0d1117;
|
||||||
|
--bg-secondary: #161b22;
|
||||||
|
--bg-tertiary: #21262d;
|
||||||
|
--text-primary: #f0f6fc;
|
||||||
|
--text-secondary: #8b949e;
|
||||||
|
--accent: #58a6ff;
|
||||||
|
--accent-hover: #79c0ff;
|
||||||
|
--success: #3fb950;
|
||||||
|
--warning: #d29922;
|
||||||
|
--error: #f85149;
|
||||||
|
--border: #30363d;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 .version {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.success { background: rgba(63, 185, 80, 0.2); color: var(--success); }
|
||||||
|
.status-badge.warning { background: rgba(210, 153, 34, 0.2); color: var(--warning); }
|
||||||
|
.status-badge.error { background: rgba(248, 81, 73, 0.2); color: var(--error); }
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 .actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value.success { color: var(--success); }
|
||||||
|
.stat-value.warning { color: var(--warning); }
|
||||||
|
.stat-value.error { color: var(--error); }
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: rgba(248, 81, 73, 0.15);
|
||||||
|
border-color: var(--error);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger:hover {
|
||||||
|
background: rgba(248, 81, 73, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-table th,
|
||||||
|
.playlist-table td {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-table th {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-table tr:hover td {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-table .track-count {
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-table .cache-age {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 120px auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section h3 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 200px 1fr auto;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item .label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item .value {
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
z-index: 1000;
|
||||||
|
animation: slideIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.success { border-color: var(--success); }
|
||||||
|
.toast.error { border-color: var(--error); }
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { transform: translateX(100%); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0,0,0,0.7);
|
||||||
|
z-index: 1000;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h3 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content .form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content .form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content .form-group input,
|
||||||
|
.modal-content .form-group select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 12px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracks-list {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 40px 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-position {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-info h4 {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-info .artists {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-meta {
|
||||||
|
text-align: right;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>
|
||||||
|
Allstarr <span class="version" id="version">v1.0.0</span>
|
||||||
|
</h1>
|
||||||
|
<div id="status-indicator">
|
||||||
|
<span class="status-badge" id="spotify-status">
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
<span>Loading...</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
||||||
|
<div class="tab" data-tab="playlists">Playlists</div>
|
||||||
|
<div class="tab" data-tab="config">Configuration</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard Tab -->
|
||||||
|
<div class="tab-content active" id="tab-dashboard">
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Spotify API</h2>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Status</span>
|
||||||
|
<span class="stat-value" id="spotify-auth-status">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">User</span>
|
||||||
|
<span class="stat-value" id="spotify-user">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Cache Duration</span>
|
||||||
|
<span class="stat-value" id="cache-duration">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">ISRC Matching</span>
|
||||||
|
<span class="stat-value" id="isrc-matching">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Jellyfin</h2>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Backend</span>
|
||||||
|
<span class="stat-value" id="backend-type">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">URL</span>
|
||||||
|
<span class="stat-value" id="jellyfin-url">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Playlists</span>
|
||||||
|
<span class="stat-value" id="playlist-count">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>
|
||||||
|
Quick Actions
|
||||||
|
</h2>
|
||||||
|
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
||||||
|
<button class="primary" onclick="refreshPlaylists()">Refresh All Playlists</button>
|
||||||
|
<button onclick="clearCache()">Clear Cache</button>
|
||||||
|
<button onclick="openAddPlaylist()">Add Playlist</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Playlists Tab -->
|
||||||
|
<div class="tab-content" id="tab-playlists">
|
||||||
|
<div class="card">
|
||||||
|
<h2>
|
||||||
|
Configured Playlists
|
||||||
|
<div class="actions">
|
||||||
|
<button onclick="refreshPlaylists()">Refresh All</button>
|
||||||
|
<button class="primary" onclick="openAddPlaylist()">Add Playlist</button>
|
||||||
|
</div>
|
||||||
|
</h2>
|
||||||
|
<table class="playlist-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Spotify ID</th>
|
||||||
|
<th>Tracks</th>
|
||||||
|
<th>Position</th>
|
||||||
|
<th>Cache Age</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="playlist-table-body">
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="loading">
|
||||||
|
<span class="spinner"></span> Loading playlists...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Configuration Tab -->
|
||||||
|
<div class="tab-content" id="tab-config">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Spotify API Settings</h2>
|
||||||
|
<div class="config-section">
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">API Enabled</span>
|
||||||
|
<span class="value" id="config-spotify-enabled">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Client ID</span>
|
||||||
|
<span class="value" id="config-spotify-client-id">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Session Cookie</span>
|
||||||
|
<span class="value" id="config-spotify-cookie">-</span>
|
||||||
|
<button onclick="openUpdateCookie()">Update</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Cache Duration</span>
|
||||||
|
<span class="value" id="config-cache-duration">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">ISRC Matching</span>
|
||||||
|
<span class="value" id="config-isrc-matching">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Jellyfin Settings</h2>
|
||||||
|
<div class="config-section">
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">URL</span>
|
||||||
|
<span class="value" id="config-jellyfin-url">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">API Key</span>
|
||||||
|
<span class="value" id="config-jellyfin-api-key">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Library ID</span>
|
||||||
|
<span class="value" id="config-jellyfin-library-id">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="background: rgba(248, 81, 73, 0.1); border-color: var(--error);">
|
||||||
|
<h2 style="color: var(--error);">Danger Zone</h2>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
|
These actions can affect your data. Use with caution.
|
||||||
|
</p>
|
||||||
|
<button class="danger" onclick="clearCache()">Clear All Cache</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Playlist Modal -->
|
||||||
|
<div class="modal" id="add-playlist-modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3>Add Playlist</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Playlist Name</label>
|
||||||
|
<input type="text" id="new-playlist-name" placeholder="e.g., Release Radar">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Spotify Playlist ID</label>
|
||||||
|
<input type="text" id="new-playlist-id" placeholder="Get from Spotify Import plugin">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Local Tracks Position</label>
|
||||||
|
<select id="new-playlist-position">
|
||||||
|
<option value="first">First (local tracks shown before Spotify tracks)</option>
|
||||||
|
<option value="last">Last (Spotify tracks shown first)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button onclick="closeModal('add-playlist-modal')">Cancel</button>
|
||||||
|
<button class="primary" onclick="addPlaylist()">Add Playlist</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Update Cookie Modal -->
|
||||||
|
<div class="modal" id="update-cookie-modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3>Update Spotify Cookie</h3>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
|
Get the sp_dc cookie from your browser's dev tools while logged into Spotify.
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>sp_dc Cookie Value</label>
|
||||||
|
<input type="text" id="new-cookie-value" placeholder="Paste cookie value here">
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button onclick="closeModal('update-cookie-modal')">Cancel</button>
|
||||||
|
<button class="primary" onclick="updateCookie()">Update Cookie</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Track List Modal -->
|
||||||
|
<div class="modal" id="tracks-modal">
|
||||||
|
<div class="modal-content" style="max-width: 700px;">
|
||||||
|
<h3 id="tracks-modal-title">Playlist Tracks</h3>
|
||||||
|
<div class="tracks-list" id="tracks-list">
|
||||||
|
<div class="loading">
|
||||||
|
<span class="spinner"></span> Loading tracks...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button onclick="closeModal('tracks-modal')">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Tab switching
|
||||||
|
document.querySelectorAll('.tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toast notification
|
||||||
|
function showToast(message, type = 'success') {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'toast ' + type;
|
||||||
|
toast.textContent = message;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
setTimeout(() => toast.remove(), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal helpers
|
||||||
|
function openModal(id) {
|
||||||
|
document.getElementById(id).classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(id) {
|
||||||
|
document.getElementById(id).classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modals on backdrop click
|
||||||
|
document.querySelectorAll('.modal').forEach(modal => {
|
||||||
|
modal.addEventListener('click', e => {
|
||||||
|
if (e.target === modal) closeModal(modal.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API calls
|
||||||
|
async function fetchStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/status');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
document.getElementById('version').textContent = 'v' + data.version;
|
||||||
|
document.getElementById('backend-type').textContent = data.backendType;
|
||||||
|
document.getElementById('jellyfin-url').textContent = data.jellyfinUrl || '-';
|
||||||
|
document.getElementById('playlist-count').textContent = data.spotifyImport.playlistCount;
|
||||||
|
document.getElementById('cache-duration').textContent = data.spotify.cacheDurationMinutes + ' min';
|
||||||
|
document.getElementById('isrc-matching').textContent = data.spotify.preferIsrcMatching ? 'Enabled' : 'Disabled';
|
||||||
|
document.getElementById('spotify-user').textContent = data.spotify.user || '-';
|
||||||
|
|
||||||
|
// Update status badge
|
||||||
|
const statusBadge = document.getElementById('spotify-status');
|
||||||
|
const authStatus = document.getElementById('spotify-auth-status');
|
||||||
|
|
||||||
|
if (data.spotify.authStatus === 'authenticated') {
|
||||||
|
statusBadge.className = 'status-badge success';
|
||||||
|
statusBadge.innerHTML = '<span class="status-dot"></span>Spotify Connected';
|
||||||
|
authStatus.textContent = 'Authenticated';
|
||||||
|
authStatus.className = 'stat-value success';
|
||||||
|
} else if (data.spotify.authStatus === 'invalid_cookie') {
|
||||||
|
statusBadge.className = 'status-badge error';
|
||||||
|
statusBadge.innerHTML = '<span class="status-dot"></span>Cookie Invalid';
|
||||||
|
authStatus.textContent = 'Invalid Cookie';
|
||||||
|
authStatus.className = 'stat-value error';
|
||||||
|
} else if (data.spotify.authStatus === 'missing_cookie') {
|
||||||
|
statusBadge.className = 'status-badge warning';
|
||||||
|
statusBadge.innerHTML = '<span class="status-dot"></span>Cookie Missing';
|
||||||
|
authStatus.textContent = 'No Cookie';
|
||||||
|
authStatus.className = 'stat-value warning';
|
||||||
|
} else {
|
||||||
|
statusBadge.className = 'status-badge';
|
||||||
|
statusBadge.innerHTML = '<span class="status-dot"></span>Not Configured';
|
||||||
|
authStatus.textContent = 'Not Configured';
|
||||||
|
authStatus.className = 'stat-value';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch status:', error);
|
||||||
|
showToast('Failed to fetch status', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPlaylists() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/playlists');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const tbody = document.getElementById('playlist-table-body');
|
||||||
|
|
||||||
|
if (data.playlists.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = data.playlists.map(p => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${escapeHtml(p.name)}</strong></td>
|
||||||
|
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${p.id || '-'}</td>
|
||||||
|
<td class="track-count">${p.trackCount || 0}</td>
|
||||||
|
<td>${p.localTracksPosition}</td>
|
||||||
|
<td class="cache-age">${p.cacheAge || '-'}</td>
|
||||||
|
<td>
|
||||||
|
<button onclick="viewTracks('${escapeHtml(p.name)}')">View</button>
|
||||||
|
<button class="danger" onclick="removePlaylist('${escapeHtml(p.name)}')">Remove</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch playlists:', error);
|
||||||
|
showToast('Failed to fetch playlists', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchConfig() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/config');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
document.getElementById('config-spotify-enabled').textContent = data.spotifyApi.enabled ? 'Yes' : 'No';
|
||||||
|
document.getElementById('config-spotify-client-id').textContent = data.spotifyApi.clientId;
|
||||||
|
document.getElementById('config-spotify-cookie').textContent = data.spotifyApi.sessionCookie;
|
||||||
|
document.getElementById('config-cache-duration').textContent = data.spotifyApi.cacheDurationMinutes + ' minutes';
|
||||||
|
document.getElementById('config-isrc-matching').textContent = data.spotifyApi.preferIsrcMatching ? 'Enabled' : 'Disabled';
|
||||||
|
document.getElementById('config-jellyfin-url').textContent = data.jellyfin.url || '-';
|
||||||
|
document.getElementById('config-jellyfin-api-key').textContent = data.jellyfin.apiKey;
|
||||||
|
document.getElementById('config-jellyfin-library-id').textContent = data.jellyfin.libraryId || '-';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch config:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshPlaylists() {
|
||||||
|
try {
|
||||||
|
showToast('Refreshing playlists...', 'success');
|
||||||
|
const res = await fetch('/api/admin/playlists/refresh', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
showToast(data.message, 'success');
|
||||||
|
setTimeout(fetchPlaylists, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to refresh playlists', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearCache() {
|
||||||
|
if (!confirm('Clear all cached playlist data?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/cache/clear', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
showToast(data.message, 'success');
|
||||||
|
fetchPlaylists();
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to clear cache', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddPlaylist() {
|
||||||
|
document.getElementById('new-playlist-name').value = '';
|
||||||
|
document.getElementById('new-playlist-id').value = '';
|
||||||
|
document.getElementById('new-playlist-position').value = 'first';
|
||||||
|
openModal('add-playlist-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addPlaylist() {
|
||||||
|
const name = document.getElementById('new-playlist-name').value.trim();
|
||||||
|
const id = document.getElementById('new-playlist-id').value.trim();
|
||||||
|
const position = document.getElementById('new-playlist-position').value;
|
||||||
|
|
||||||
|
if (!name || !id) {
|
||||||
|
showToast('Name and ID are required', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/playlists', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, spotifyId: id, localTracksPosition: position })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Playlist added. Restart container to apply.', 'success');
|
||||||
|
closeModal('add-playlist-modal');
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Failed to add playlist', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to add playlist', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePlaylist(name) {
|
||||||
|
if (!confirm(`Remove playlist "${name}"?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(name), {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Playlist removed. Restart container to apply.', 'success');
|
||||||
|
fetchPlaylists();
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Failed to remove playlist', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to remove playlist', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewTracks(name) {
|
||||||
|
document.getElementById('tracks-modal-title').textContent = name + ' - Tracks';
|
||||||
|
document.getElementById('tracks-list').innerHTML = '<div class="loading"><span class="spinner"></span> Loading tracks...</div>';
|
||||||
|
openModal('tracks-modal');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(name) + '/tracks');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.tracks.length === 0) {
|
||||||
|
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:40px;">No tracks found</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('tracks-list').innerHTML = data.tracks.map(t => `
|
||||||
|
<div class="track-item">
|
||||||
|
<span class="track-position">${t.position + 1}</span>
|
||||||
|
<div class="track-info">
|
||||||
|
<h4>${escapeHtml(t.title)}</h4>
|
||||||
|
<span class="artists">${escapeHtml(t.artists.join(', '))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="track-meta">
|
||||||
|
${t.album ? escapeHtml(t.album) : ''}
|
||||||
|
${t.isrc ? '<br><small>ISRC: ' + t.isrc + '</small>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (error) {
|
||||||
|
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUpdateCookie() {
|
||||||
|
document.getElementById('new-cookie-value').value = '';
|
||||||
|
openModal('update-cookie-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateCookie() {
|
||||||
|
const cookie = document.getElementById('new-cookie-value').value.trim();
|
||||||
|
|
||||||
|
if (!cookie) {
|
||||||
|
showToast('Cookie value is required', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ updates: { 'SPOTIFY_API_SESSION_COOKIE': cookie } })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Cookie updated. Restart container to apply.', 'success');
|
||||||
|
closeModal('update-cookie-modal');
|
||||||
|
fetchConfig();
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Failed to update cookie', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to update cookie', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
fetchStatus();
|
||||||
|
fetchPlaylists();
|
||||||
|
fetchConfig();
|
||||||
|
|
||||||
|
// Auto-refresh every 30 seconds
|
||||||
|
setInterval(() => {
|
||||||
|
fetchStatus();
|
||||||
|
fetchPlaylists();
|
||||||
|
}, 30000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user