diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index 918f463..810fc34 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -2015,6 +2015,284 @@ public class AdminController : ControllerBase GC.Collect(2, GCCollectionMode.Optimized, blocking: false); } } + + #region Spotify Admin Endpoints + + /// + /// Manual trigger endpoint to force fetch Spotify missing tracks. + /// + [HttpGet("spotify/sync")] + public async Task TriggerSpotifySync([FromServices] IEnumerable hostedServices) + { + try + { + if (!_spotifyImportSettings.Enabled) + { + return BadRequest(new { error = "Spotify Import is not enabled" }); + } + + _logger.LogInformation("Manual Spotify sync triggered via admin endpoint"); + + // Find the SpotifyMissingTracksFetcher service + var fetcherService = hostedServices + .OfType() + .FirstOrDefault(); + + if (fetcherService == null) + { + return BadRequest(new { error = "SpotifyMissingTracksFetcher service not found" }); + } + + // Trigger the sync in background + _ = Task.Run(async () => + { + try + { + // Use reflection to call the private ExecuteOnceAsync method + var method = fetcherService.GetType().GetMethod("ExecuteOnceAsync", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + if (method != null) + { + await (Task)method.Invoke(fetcherService, new object[] { CancellationToken.None })!; + _logger.LogInformation("Manual Spotify sync completed successfully"); + } + else + { + _logger.LogError("Could not find ExecuteOnceAsync method on SpotifyMissingTracksFetcher"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during manual Spotify sync"); + } + }); + + return Ok(new { + message = "Spotify sync started in background", + timestamp = DateTime.UtcNow + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error triggering Spotify sync"); + return StatusCode(500, new { error = "Internal server error" }); + } + } + + /// + /// Manual trigger endpoint to force Spotify track matching. + /// + [HttpGet("spotify/match")] + public async Task TriggerSpotifyMatch([FromServices] IEnumerable hostedServices) + { + try + { + if (!_spotifyApiSettings.Enabled) + { + return BadRequest(new { error = "Spotify API is not enabled" }); + } + + _logger.LogInformation("Manual Spotify track matching triggered via admin endpoint"); + + // Find the SpotifyTrackMatchingService + var matchingService = hostedServices + .OfType() + .FirstOrDefault(); + + if (matchingService == null) + { + return BadRequest(new { error = "SpotifyTrackMatchingService not found" }); + } + + // Trigger matching in background + _ = Task.Run(async () => + { + try + { + // Use reflection to call the private ExecuteOnceAsync method + var method = matchingService.GetType().GetMethod("ExecuteOnceAsync", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + if (method != null) + { + await (Task)method.Invoke(matchingService, new object[] { CancellationToken.None })!; + _logger.LogInformation("Manual Spotify track matching completed successfully"); + } + else + { + _logger.LogError("Could not find ExecuteOnceAsync method on SpotifyTrackMatchingService"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during manual Spotify track matching"); + } + }); + + return Ok(new { + message = "Spotify track matching started in background", + timestamp = DateTime.UtcNow + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error triggering Spotify track matching"); + return StatusCode(500, new { error = "Internal server error" }); + } + } + + /// + /// Clear Spotify playlist cache to force re-matching. + /// + [HttpPost("spotify/clear-cache")] + public async Task ClearSpotifyCache() + { + try + { + var clearedKeys = new List(); + + // Clear Redis cache for all configured playlists + foreach (var playlist in _spotifyImportSettings.Playlists) + { + var keys = new[] + { + $"spotify:playlist:{playlist.Name}", + $"spotify:playlist:items:{playlist.Name}", + $"spotify:matched:{playlist.Name}" + }; + + foreach (var key in keys) + { + await _cache.DeleteAsync(key); + clearedKeys.Add(key); + } + } + + _logger.LogInformation("Cleared Spotify cache for {Count} keys via admin endpoint", clearedKeys.Count); + + return Ok(new { + message = "Spotify cache cleared successfully", + clearedKeys = clearedKeys, + timestamp = DateTime.UtcNow + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error clearing Spotify cache"); + return StatusCode(500, new { error = "Internal server error" }); + } + } + + #endregion + + #region Debug Endpoints + + /// + /// Gets endpoint usage statistics from the log file. + /// + [HttpGet("debug/endpoint-usage")] + public async Task GetEndpointUsage( + [FromQuery] int top = 100, + [FromQuery] string? since = null) + { + try + { + var logFile = "/app/cache/endpoint-usage/endpoints.csv"; + + if (!System.IO.File.Exists(logFile)) + { + return Ok(new { + message = "No endpoint usage data available", + endpoints = new object[0] + }); + } + + var lines = await System.IO.File.ReadAllLinesAsync(logFile); + var usage = new Dictionary(); + DateTime? sinceDate = null; + + if (!string.IsNullOrEmpty(since) && DateTime.TryParse(since, out var parsedDate)) + { + sinceDate = parsedDate; + } + + foreach (var line in lines.Skip(1)) // Skip header + { + var parts = line.Split(','); + if (parts.Length >= 3) + { + var timestamp = parts[0]; + var endpoint = parts[1]; + + // Filter by date if specified + if (sinceDate.HasValue && DateTime.TryParse(timestamp, out var logDate)) + { + if (logDate < sinceDate.Value) + continue; + } + + usage[endpoint] = usage.GetValueOrDefault(endpoint, 0) + 1; + } + } + + var topEndpoints = usage + .OrderByDescending(kv => kv.Value) + .Take(top) + .Select(kv => new { endpoint = kv.Key, count = kv.Value }) + .ToArray(); + + return Ok(new { + totalEndpoints = usage.Count, + totalRequests = usage.Values.Sum(), + since = since, + top = top, + endpoints = topEndpoints + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting endpoint usage"); + return StatusCode(500, new { error = "Internal server error" }); + } + } + + /// + /// Clears the endpoint usage log file. + /// + [HttpDelete("debug/endpoint-usage")] + public IActionResult ClearEndpointUsage() + { + try + { + var logFile = "/app/cache/endpoint-usage/endpoints.csv"; + + if (System.IO.File.Exists(logFile)) + { + System.IO.File.Delete(logFile); + _logger.LogInformation("Cleared endpoint usage log via admin endpoint"); + + return Ok(new { + message = "Endpoint usage log cleared successfully", + timestamp = DateTime.UtcNow + }); + } + else + { + return Ok(new { + message = "No endpoint usage log file found", + timestamp = DateTime.UtcNow + }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error clearing endpoint usage log"); + return StatusCode(500, new { error = "Internal server error" }); + } + } + + #endregion } public class ConfigUpdateRequest diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index bf15e4c..ebbeda0 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -3979,284 +3979,8 @@ public class JellyfinController : ControllerBase } } - /// - /// Manual trigger endpoint to force fetch Spotify missing tracks. - /// GET /spotify/sync?api_key=YOUR_KEY - /// - [HttpGet("spotify/sync", Order = 1)] - [ServiceFilter(typeof(ApiKeyAuthFilter))] - public async Task TriggerSpotifySync([FromServices] IEnumerable hostedServices) - { - if (!_spotifySettings.Enabled) - { - return BadRequest(new { error = "Spotify Import is not enabled" }); - } - - _logger.LogInformation("Manual Spotify sync triggered"); - - // Find the SpotifyMissingTracksFetcher service - var fetcherService = hostedServices - .OfType() - .FirstOrDefault(); - - if (fetcherService == null) - { - return StatusCode(500, new { error = "SpotifyMissingTracksFetcher not found" }); - } - - // Trigger fetch manually - await fetcherService.TriggerFetchAsync(); - - // Check what was cached - var results = new Dictionary(); - foreach (var playlist in _spotifySettings.Playlists) - { - var cacheKey = $"spotify:missing:{playlist.Name}"; - var tracks = await _cache.GetAsync>(cacheKey); - - if (tracks != null && tracks.Count > 0) - { - results[playlist.Name] = new { - status = "success", - tracks = tracks.Count, - localTracksPosition = playlist.LocalTracksPosition.ToString() - }; - } - else - { - results[playlist.Name] = new { - status = "not_found", - message = "No missing tracks found" - }; - } - } - - return Ok(results); - } - - /// - /// Manually trigger track matching for all Spotify playlists. - /// GET /spotify/match?api_key=YOUR_KEY - /// - [HttpGet("spotify/match", Order = 1)] - [ServiceFilter(typeof(ApiKeyAuthFilter))] - public async Task TriggerSpotifyMatch([FromServices] IEnumerable hostedServices) - { - if (!_spotifySettings.Enabled) - { - return BadRequest(new { error = "Spotify Import is not enabled" }); - } - - _logger.LogInformation("Manual Spotify track matching triggered"); - - // Find the SpotifyTrackMatchingService - var matchingService = hostedServices - .OfType() - .FirstOrDefault(); - - if (matchingService == null) - { - return StatusCode(500, new { error = "SpotifyTrackMatchingService not found" }); - } - - // Trigger matching asynchronously - _ = Task.Run(async () => - { - try - { - await matchingService.TriggerMatchingAsync(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error during manual track matching"); - } - }); - - return Ok(new - { - status = "started", - message = "Track matching started in background. Check logs for progress.", - playlists = _spotifySettings.Playlists.Select(p => new { p.Name, p.Id, localTracksPosition = p.LocalTracksPosition.ToString() }) - }); - } - - private List ParseMissingTracksJson(string json) - { - var tracks = new List(); - - try - { - var doc = JsonDocument.Parse(json); - - foreach (var item in doc.RootElement.EnumerateArray()) - { - var track = new allstarr.Models.Spotify.MissingTrack - { - SpotifyId = item.GetProperty("Id").GetString() ?? "", - Title = item.GetProperty("Name").GetString() ?? "", - Album = item.GetProperty("AlbumName").GetString() ?? "", - Artists = item.GetProperty("ArtistNames") - .EnumerateArray() - .Select(a => a.GetString() ?? "") - .Where(a => !string.IsNullOrEmpty(a)) - .ToList() - }; - - if (!string.IsNullOrEmpty(track.Title)) - { - tracks.Add(track); - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to parse missing tracks JSON"); - } - - return tracks; - } - #endregion - - #region Spotify Debug - - /// - /// Clear Spotify playlist cache to force re-matching. - /// GET /spotify/clear-cache?api_key=YOUR_KEY - /// - [HttpGet("spotify/clear-cache")] - [ServiceFilter(typeof(ApiKeyAuthFilter))] - public async Task ClearSpotifyCache() - { - if (!_spotifySettings.Enabled) - { - return BadRequest(new { error = "Spotify Import is not enabled" }); - } - var cleared = new List(); - - foreach (var playlist in _spotifySettings.Playlists) - { - var matchedKey = $"spotify:matched:{playlist.Name}"; - await _cache.DeleteAsync(matchedKey); - cleared.Add(playlist.Name); - _logger.LogInformation("Cleared cache for {Playlist}", playlist.Name); - } - - return Ok(new { status = "success", cleared = cleared }); - } - - #endregion - - #region Debug & Monitoring - - /// - /// Gets endpoint usage statistics from the log file. - /// GET /debug/endpoint-usage?api_key=YOUR_KEY - /// Optional query params: top=50 (default 100), since=2024-01-01 - /// - [HttpGet("debug/endpoint-usage")] - [ServiceFilter(typeof(ApiKeyAuthFilter))] - public async Task GetEndpointUsage( - [FromQuery] int top = 100, - [FromQuery] string? since = null) - { - try - { - var logFile = "/app/cache/endpoint-usage/endpoints.csv"; - - if (!System.IO.File.Exists(logFile)) - { - return Ok(new - { - message = "No endpoint usage data collected yet", - endpoints = Array.Empty() - }); - } - - var lines = await System.IO.File.ReadAllLinesAsync(logFile); - - // Parse CSV and filter by date if provided - DateTime? sinceDate = null; - if (!string.IsNullOrEmpty(since) && DateTime.TryParse(since, out var parsedDate)) - { - sinceDate = parsedDate; - } - - var entries = lines - .Select(line => line.Split(',')) - .Where(parts => parts.Length >= 3) - .Where(parts => !sinceDate.HasValue || - (DateTime.TryParse(parts[0], out var entryDate) && entryDate >= sinceDate.Value)) - .Select(parts => new - { - Timestamp = parts[0], - Method = parts.Length > 1 ? parts[1] : "", - Path = parts.Length > 2 ? parts[2] : "", - Query = parts.Length > 3 ? parts[3] : "" - }) - .ToList(); - - // Group by path and count - var pathCounts = entries - .GroupBy(e => new { e.Method, e.Path }) - .Select(g => new - { - Method = g.Key.Method, - Path = g.Key.Path, - Count = g.Count(), - FirstSeen = g.Min(e => e.Timestamp), - LastSeen = g.Max(e => e.Timestamp) - }) - .OrderByDescending(x => x.Count) - .Take(top) - .ToList(); - - return Ok(new - { - totalRequests = entries.Count, - uniqueEndpoints = pathCounts.Count, - topEndpoints = pathCounts, - logFile = logFile, - logSize = new FileInfo(logFile).Length - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get endpoint usage"); - return StatusCode(500, new { error = ex.Message }); - } - } - - /// - /// Clears the endpoint usage log file. - /// DELETE /debug/endpoint-usage?api_key=YOUR_KEY - /// - [HttpDelete("debug/endpoint-usage")] - [ServiceFilter(typeof(ApiKeyAuthFilter))] - public IActionResult ClearEndpointUsage() - { - try - { - var logFile = "/app/cache/endpoint-usage/endpoints.csv"; - - if (System.IO.File.Exists(logFile)) - { - System.IO.File.Delete(logFile); - return Ok(new { status = "success", message = "Endpoint usage log cleared" }); - } - - return Ok(new { status = "success", message = "No log file to clear" }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to clear endpoint usage log"); - return StatusCode(500, new { error = ex.Message }); - } - } - - #endregion - /// /// Calculates artist match score ensuring ALL artists are present. /// Penalizes if artist counts don't match or if any artist is missing.