Compare commits

...

17 Commits

Author SHA1 Message Date
6ccc6a4a0d debug: add logging to verify Spotify IDs in cached playlist items
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-02-06 14:15:49 -05:00
c54503f486 fix: Spotify lyrics validation and proactive prefetching
- Only attempt Spotify lyrics for tracks with valid Spotify IDs (22 chars, no 'local' or ':')
- Add Spotify IDs to external matched tracks in playlists for lyrics support
- Proactively fetch and cache lyrics when playback starts (background task)
- Fix pre-existing SubSonicController bug (missing _cache field)
- Lyrics now ready instantly when requested by client
2026-02-06 13:04:40 -05:00
fbac81df64 feat: add 1-hour cache for playlist cover images 2026-02-06 12:31:56 -05:00
3a433e276c refactor: reorganize apis folder into steering and api-calls 2026-02-06 12:20:54 -05:00
0c14f4a760 chore: explicitly ignore documentation files in apis folder 2026-02-06 12:16:39 -05:00
28c4f8f5df Remove local Jellyfin manual mapping, keep only external mappings 2026-02-06 12:05:26 -05:00
a3830c54c4 Use Jellyfin item IDs for lyrics check instead of searching
- Lyrics prefetch now uses playlist items cache which has Jellyfin item IDs
- Directly queries /Audio/{itemId}/Lyrics endpoint (no search needed)
- Eliminates all 401 errors and 'no client headers' warnings
- Priority order: 1) Local Jellyfin lyrics, 2) Spotify lyrics API, 3) LRCLib
- Much more efficient - no fuzzy searching required
- Only searches by artist/title as fallback if item ID not available
- All 225 tests passing
2026-02-06 11:53:35 -05:00
4226ead53a Add file-based caching for admin UI and fix Jellyfin API usage
- Added 5-minute file cache for playlist summary to speed up admin UI loads
- Added refresh parameter to force cache bypass
- Invalidate cache when playlists are refreshed or tracks are matched
- Fixed incorrect use of anyProviderIdEquals (Emby API) in Jellyfin
- Now searches Jellyfin by artist and title instead of provider ID
- Fixes 401 errors and 'no client headers' warnings in lyrics prefetch
- All 225 tests passing
2026-02-06 11:48:01 -05:00
2155c4a9d5 Fix delete button for manual track mappings
- Use data attributes instead of inline onclick to avoid quote escaping issues
- Add event listeners after rendering the table
- Fixes issue where Remove button didn't work due to escaped quotes in onclick attribute
2026-02-06 11:42:01 -05:00
a56b2c3ea3 Add delete button for manual track mappings
- Added DELETE /api/admin/mappings/tracks endpoint
- Removes mapping from JSON file and Redis cache
- Deletes file if it becomes empty after removal
- Added 'Remove' button to each mapping in web UI
- Enhanced confirm dialog explaining consequences for both local and external mappings
- Supports removing both Jellyfin (local) and external provider mappings
- Allows phasing out local mappings in favor of Spotify Import plugin
2026-02-06 11:36:51 -05:00
810247ba8c Add manual track mappings display to web UI
- Shows all manual mappings in Active Playlists tab
- Displays summary counts (total, jellyfin, external)
- Table shows playlist, Spotify ID, type, target, and creation date
- Color-coded badges for jellyfin vs external mappings
- Auto-refreshes every 30 seconds
- Helps review mappings before phasing out local ones
2026-02-06 11:18:48 -05:00
96814aa91b Add endpoint to view all manual track mappings
- GET /api/admin/mappings/tracks returns all manual mappings
- Shows both Jellyfin (local) and external provider mappings
- Groups by playlist and includes creation timestamps
- Returns counts for jellyfin vs external mappings
2026-02-06 11:16:09 -05:00
d52c0fc938 Add Spotify ID lookup for external tracks to enable Spotify lyrics
- External tracks from playlists now look up their Spotify ID from matched tracks cache
- Enables Spotify lyrics API to work for SquidWTF/Deezer/Qobuz tracks
- Searches through all playlist matched tracks to find the Spotify ID
- Falls back to LRCLIB if no Spotify ID found or lyrics unavailable
2026-02-06 11:14:55 -05:00
64eff088fa Remove incorrect healthcheck from spotify-lyrics service 2026-02-06 11:02:00 -05:00
ff6dfede87 Change Spotify lyrics API external port to 8365 2026-02-06 10:54:43 -05:00
d8696e254f Expose Spotify lyrics API on port 8080 for testing 2026-02-06 10:52:44 -05:00
261f20f378 Add Spotify lyrics test endpoint
- Add GET /api/admin/lyrics/spotify/test endpoint
- Accepts trackId query parameter (Spotify track ID)
- Returns lyrics in both JSON and LRC format
- Useful for testing Spotify lyrics API integration
2026-02-06 10:51:16 -05:00
9 changed files with 1112 additions and 561 deletions

10
.gitignore vendored
View File

@@ -84,15 +84,15 @@ cache/
redis-data/ redis-data/
# API keys and specs (ignore markdown docs, keep OpenAPI spec) # API keys and specs (ignore markdown docs, keep OpenAPI spec)
apis/*.md apis/steering/
apis/*.json apis/api-calls/*.json
!apis/jellyfin-openapi-stable.json !apis/api-calls/jellyfin-openapi-stable.json
# Log files for debugging # Log files for debugging
apis/*.log apis/api-calls/*.log
# Endpoint usage tracking # Endpoint usage tracking
apis/endpoint-usage.json apis/api-calls/endpoint-usage.json
/app/cache/endpoint-usage/ /app/cache/endpoint-usage/
# Original source code for reference # Original source code for reference

View File

@@ -211,8 +211,40 @@ public class AdminController : ControllerBase
/// Get list of configured playlists with their current data /// Get list of configured playlists with their current data
/// </summary> /// </summary>
[HttpGet("playlists")] [HttpGet("playlists")]
public async Task<IActionResult> GetPlaylists() public async Task<IActionResult> GetPlaylists([FromQuery] bool refresh = false)
{ {
var playlistCacheFile = "/app/cache/admin_playlists_summary.json";
// Check file cache first (5 minute TTL) unless refresh is requested
if (!refresh && System.IO.File.Exists(playlistCacheFile))
{
try
{
var fileInfo = new FileInfo(playlistCacheFile);
var age = DateTime.UtcNow - fileInfo.LastWriteTimeUtc;
if (age.TotalMinutes < 5)
{
var cachedJson = await System.IO.File.ReadAllTextAsync(playlistCacheFile);
var cachedData = JsonSerializer.Deserialize<Dictionary<string, object>>(cachedJson);
_logger.LogDebug("📦 Returning cached playlist summary (age: {Age:F1}m)", age.TotalMinutes);
return Ok(cachedData);
}
else
{
_logger.LogDebug("🔄 Cache expired (age: {Age:F1}m), refreshing...", age.TotalMinutes);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read cached playlist summary");
}
}
else if (refresh)
{
_logger.LogInformation("🔄 Force refresh requested for playlist summary");
}
var playlists = new List<object>(); var playlists = new List<object>();
// Read playlists directly from .env file to get the latest configuration // Read playlists directly from .env file to get the latest configuration
@@ -541,6 +573,24 @@ public class AdminController : ControllerBase
playlists.Add(playlistInfo); playlists.Add(playlistInfo);
} }
// Save to file cache
try
{
var cacheDir = "/app/cache";
Directory.CreateDirectory(cacheDir);
var cacheFile = Path.Combine(cacheDir, "admin_playlists_summary.json");
var response = new { playlists };
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = false });
await System.IO.File.WriteAllTextAsync(cacheFile, json);
_logger.LogDebug("💾 Saved playlist summary to cache");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to save playlist summary cache");
}
return Ok(new { playlists }); return Ok(new { playlists });
} }
@@ -619,26 +669,10 @@ public class AdminController : ControllerBase
{ {
bool? isLocal = null; bool? isLocal = null;
string? externalProvider = null; string? externalProvider = null;
// FIRST: Check for manual mapping (same as SpotifyTrackMatchingService)
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
bool isManualMapping = false; bool isManualMapping = false;
string? manualMappingType = null; string? manualMappingType = null;
string? manualMappingId = null; string? manualMappingId = null;
if (!string.IsNullOrEmpty(manualJellyfinId))
{
// Manual Jellyfin mapping exists - this track is definitely local
isLocal = true;
isManualMapping = true;
manualMappingType = "jellyfin";
manualMappingId = manualJellyfinId;
_logger.LogDebug("✓ Manual Jellyfin mapping found for {Title}: Jellyfin ID {Id}",
track.Title, manualJellyfinId);
}
else
{
// Check for external manual mapping // Check for external manual mapping
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}"; var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey); var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
@@ -680,9 +714,10 @@ public class AdminController : ControllerBase
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title); _logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
} }
} }
else if (localTracks.Count > 0)
// If no manual mapping, try fuzzy matching with local tracks
if (!isManualMapping && localTracks.Count > 0)
{ {
// SECOND: No manual mapping, try fuzzy matching
var bestMatch = localTracks var bestMatch = localTracks
.Select(local => new .Select(local => new
{ {
@@ -706,7 +741,6 @@ public class AdminController : ControllerBase
isLocal = true; isLocal = true;
} }
} }
}
// If not local, check if it's externally matched or missing // If not local, check if it's externally matched or missing
if (isLocal != true) if (isLocal != true)
@@ -782,21 +816,14 @@ public class AdminController : ControllerBase
fallbackMatchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>() fallbackMatchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
); );
// Clear and reuse tracksWithStatus for fallback
tracksWithStatus.Clear();
foreach (var track in spotifyTracks) foreach (var track in spotifyTracks)
{ {
bool? isLocal = null; bool? isLocal = null;
string? externalProvider = null; string? externalProvider = null;
// Check for manual mappings
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId))
{
isLocal = true;
}
else
{
// Check for external manual mapping // Check for external manual mapping
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}"; var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey); var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
@@ -838,7 +865,6 @@ public class AdminController : ControllerBase
isLocal = null; isLocal = null;
externalProvider = null; externalProvider = null;
} }
}
tracksWithStatus.Add(new tracksWithStatus.Add(new
{ {
@@ -872,6 +898,10 @@ public class AdminController : ControllerBase
{ {
_logger.LogInformation("Manual playlist refresh triggered from admin UI"); _logger.LogInformation("Manual playlist refresh triggered from admin UI");
await _playlistFetcher.TriggerFetchAsync(); await _playlistFetcher.TriggerFetchAsync();
// Invalidate playlist summary cache
InvalidatePlaylistSummaryCache();
return Ok(new { message = "Playlist refresh triggered", timestamp = DateTime.UtcNow }); return Ok(new { message = "Playlist refresh triggered", timestamp = DateTime.UtcNow });
} }
@@ -892,6 +922,10 @@ public class AdminController : ControllerBase
try try
{ {
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName); await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
// Invalidate playlist summary cache
InvalidatePlaylistSummaryCache();
return Ok(new { message = $"Track matching triggered for {decodedName}", timestamp = DateTime.UtcNow }); return Ok(new { message = $"Track matching triggered for {decodedName}", timestamp = DateTime.UtcNow });
} }
catch (Exception ex) catch (Exception ex)
@@ -1053,7 +1087,8 @@ public class AdminController : ControllerBase
} }
/// <summary> /// <summary>
/// Save manual track mapping /// Save manual external track mapping (SquidWTF/Deezer/Qobuz)
/// Note: Local Jellyfin mappings should be done via Spotify Import plugin
/// </summary> /// </summary>
[HttpPost("playlists/{name}/map")] [HttpPost("playlists/{name}/map")]
public async Task<IActionResult> SaveManualMapping(string name, [FromBody] ManualMappingRequest request) public async Task<IActionResult> SaveManualMapping(string name, [FromBody] ManualMappingRequest request)
@@ -1065,35 +1100,14 @@ public class AdminController : ControllerBase
return BadRequest(new { error = "SpotifyId is required" }); return BadRequest(new { error = "SpotifyId is required" });
} }
// Validate that either Jellyfin mapping or external mapping is provided // Only external mappings are supported now
var hasJellyfinMapping = !string.IsNullOrWhiteSpace(request.JellyfinId); // Local Jellyfin mappings should be done via Spotify Import plugin
var hasExternalMapping = !string.IsNullOrWhiteSpace(request.ExternalProvider) && !string.IsNullOrWhiteSpace(request.ExternalId); if (string.IsNullOrWhiteSpace(request.ExternalProvider) || string.IsNullOrWhiteSpace(request.ExternalId))
if (!hasJellyfinMapping && !hasExternalMapping)
{ {
return BadRequest(new { error = "Either JellyfinId or (ExternalProvider + ExternalId) is required" }); return BadRequest(new { error = "ExternalProvider and ExternalId are required. Use Spotify Import plugin for local Jellyfin mappings." });
}
if (hasJellyfinMapping && hasExternalMapping)
{
return BadRequest(new { error = "Cannot specify both Jellyfin and external mapping for the same track" });
} }
try try
{
if (hasJellyfinMapping)
{
// Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent)
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
await _cache.SetAsync(mappingKey, request.JellyfinId!);
// Also save to file for persistence across restarts
await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, request.JellyfinId!, null, null);
_logger.LogInformation("Manual Jellyfin mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}",
decodedName, request.SpotifyId, request.JellyfinId);
}
else
{ {
// Store external mapping in cache (NO EXPIRATION - manual mappings are permanent) // Store external mapping in cache (NO EXPIRATION - manual mappings are permanent)
var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}"; var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
@@ -1106,7 +1120,6 @@ public class AdminController : ControllerBase
_logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}", _logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}",
decodedName, request.SpotifyId, normalizedProvider, request.ExternalId); decodedName, request.SpotifyId, normalizedProvider, request.ExternalId);
}
// Clear all related caches to force rebuild // Clear all related caches to force rebuild
var matchedCacheKey = $"spotify:matched:{decodedName}"; var matchedCacheKey = $"spotify:matched:{decodedName}";
@@ -1144,58 +1157,14 @@ public class AdminController : ControllerBase
_logger.LogInformation("Cleared playlist caches for {Playlist} to force rebuild", decodedName); _logger.LogInformation("Cleared playlist caches for {Playlist} to force rebuild", decodedName);
// Fetch the mapped track details to return to the UI // Fetch external provider track details to return to the UI
string? trackTitle = null; string? trackTitle = null;
string? trackArtist = null; string? trackArtist = null;
string? trackAlbum = null; string? trackAlbum = null;
bool isLocalMapping = hasJellyfinMapping;
if (hasJellyfinMapping)
{
// Fetch Jellyfin track details
try
{
var userId = _jellyfinSettings.UserId;
var trackUrl = $"{_jellyfinSettings.Url}/Items/{request.JellyfinId}";
if (!string.IsNullOrEmpty(userId))
{
trackUrl += $"?UserId={userId}";
}
var trackRequest = new HttpRequestMessage(HttpMethod.Get, trackUrl);
trackRequest.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
var response = await _jellyfinHttpClient.SendAsync(trackRequest);
if (response.IsSuccessStatusCode)
{
var trackData = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(trackData);
var track = doc.RootElement;
trackTitle = track.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null;
trackArtist = track.TryGetProperty("AlbumArtist", out var artistEl) ? artistEl.GetString() :
(track.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0
? artistsEl[0].GetString() : null);
trackAlbum = track.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : null;
}
else
{
_logger.LogWarning("Failed to fetch Jellyfin track {Id}: {StatusCode}", request.JellyfinId, response.StatusCode);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch mapped track details, but mapping was saved");
}
}
else
{
// Fetch external provider track details
try try
{ {
var metadataService = HttpContext.RequestServices.GetRequiredService<IMusicMetadataService>(); var metadataService = HttpContext.RequestServices.GetRequiredService<IMusicMetadataService>();
var normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
var externalSong = await metadataService.GetSongAsync(normalizedProvider, request.ExternalId!); var externalSong = await metadataService.GetSongAsync(normalizedProvider, request.ExternalId!);
if (externalSong != null) if (externalSong != null)
@@ -1215,7 +1184,6 @@ public class AdminController : ControllerBase
{ {
_logger.LogWarning(ex, "Failed to fetch external track metadata, but mapping was saved"); _logger.LogWarning(ex, "Failed to fetch external track metadata, but mapping was saved");
} }
}
// Trigger immediate playlist rebuild with the new mapping // Trigger immediate playlist rebuild with the new mapping
if (_matchingService != null) if (_matchingService != null)
@@ -1249,12 +1217,12 @@ public class AdminController : ControllerBase
// Return success with track details if available // Return success with track details if available
var mappedTrack = new var mappedTrack = new
{ {
id = hasJellyfinMapping ? request.JellyfinId : request.ExternalId, id = request.ExternalId,
title = trackTitle ?? "Unknown", title = trackTitle ?? "Unknown",
artist = trackArtist ?? "Unknown", artist = trackArtist ?? "Unknown",
album = trackAlbum ?? "Unknown", album = trackAlbum ?? "Unknown",
isLocal = isLocalMapping, isLocal = false,
externalProvider = hasExternalMapping ? request.ExternalProvider!.ToLowerInvariant() : null externalProvider = request.ExternalProvider!.ToLowerInvariant()
}; };
return Ok(new return Ok(new
@@ -2891,6 +2859,200 @@ public class AdminController : ControllerBase
} }
} }
/// <summary>
/// Get all manual track mappings (both Jellyfin and external) for all playlists
/// </summary>
[HttpGet("mappings/tracks")]
public async Task<IActionResult> GetAllTrackMappings()
{
try
{
var mappingsDir = "/app/cache/mappings";
var allMappings = new List<object>();
if (!Directory.Exists(mappingsDir))
{
return Ok(new { mappings = allMappings, totalCount = 0 });
}
var files = Directory.GetFiles(mappingsDir, "*_mappings.json");
foreach (var file in files)
{
try
{
var json = await System.IO.File.ReadAllTextAsync(file);
var playlistMappings = JsonSerializer.Deserialize<Dictionary<string, ManualMappingEntry>>(json);
if (playlistMappings != null)
{
var fileName = Path.GetFileNameWithoutExtension(file);
var playlistName = fileName.Replace("_mappings", "").Replace("_", " ");
foreach (var mapping in playlistMappings.Values)
{
allMappings.Add(new
{
playlist = playlistName,
spotifyId = mapping.SpotifyId,
type = !string.IsNullOrEmpty(mapping.JellyfinId) ? "jellyfin" : "external",
jellyfinId = mapping.JellyfinId,
externalProvider = mapping.ExternalProvider,
externalId = mapping.ExternalId,
createdAt = mapping.CreatedAt
});
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read mapping file {File}", file);
}
}
return Ok(new
{
mappings = allMappings.OrderBy(m => ((dynamic)m).playlist).ThenBy(m => ((dynamic)m).createdAt),
totalCount = allMappings.Count,
jellyfinCount = allMappings.Count(m => ((dynamic)m).type == "jellyfin"),
externalCount = allMappings.Count(m => ((dynamic)m).type == "external")
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get track mappings");
return StatusCode(500, new { error = "Failed to get track mappings" });
}
}
/// <summary>
/// Delete a manual track mapping
/// </summary>
[HttpDelete("mappings/tracks")]
public async Task<IActionResult> DeleteTrackMapping([FromQuery] string playlist, [FromQuery] string spotifyId)
{
if (string.IsNullOrEmpty(playlist) || string.IsNullOrEmpty(spotifyId))
{
return BadRequest(new { error = "playlist and spotifyId parameters are required" });
}
try
{
var mappingsDir = "/app/cache/mappings";
var safeName = string.Join("_", playlist.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine(mappingsDir, $"{safeName}_mappings.json");
if (!System.IO.File.Exists(filePath))
{
return NotFound(new { error = "Mapping file not found for playlist" });
}
// Load existing mappings
var json = await System.IO.File.ReadAllTextAsync(filePath);
var mappings = JsonSerializer.Deserialize<Dictionary<string, ManualMappingEntry>>(json);
if (mappings == null || !mappings.ContainsKey(spotifyId))
{
return NotFound(new { error = "Mapping not found" });
}
// Remove the mapping
mappings.Remove(spotifyId);
// Save back to file (or delete file if empty)
if (mappings.Count == 0)
{
System.IO.File.Delete(filePath);
_logger.LogInformation("🗑️ Deleted empty mapping file for playlist {Playlist}", playlist);
}
else
{
var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(filePath, updatedJson);
_logger.LogInformation("🗑️ Deleted mapping: {Playlist} - {SpotifyId}", playlist, spotifyId);
}
// Also remove from Redis cache
var cacheKey = $"manual:mapping:{playlist}:{spotifyId}";
await _cache.DeleteAsync(cacheKey);
return Ok(new { success = true, message = "Mapping deleted successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete track mapping for {Playlist} - {SpotifyId}", playlist, spotifyId);
return StatusCode(500, new { error = "Failed to delete track mapping" });
}
}
/// <summary>
/// Test Spotify lyrics API by fetching lyrics for a specific Spotify track ID
/// Example: GET /api/admin/lyrics/spotify/test?trackId=3yII7UwgLF6K5zW3xad3MP
/// </summary>
[HttpGet("lyrics/spotify/test")]
public async Task<IActionResult> TestSpotifyLyrics([FromQuery] string trackId)
{
if (string.IsNullOrEmpty(trackId))
{
return BadRequest(new { error = "trackId parameter is required" });
}
try
{
var spotifyLyricsService = _serviceProvider.GetService<allstarr.Services.Lyrics.SpotifyLyricsService>();
if (spotifyLyricsService == null)
{
return StatusCode(500, new { error = "Spotify lyrics service not available" });
}
_logger.LogInformation("Testing Spotify lyrics for track ID: {TrackId}", trackId);
var result = await spotifyLyricsService.GetLyricsByTrackIdAsync(trackId);
if (result == null)
{
return NotFound(new
{
error = "No lyrics found",
trackId,
message = "Lyrics may not be available for this track, or the Spotify API is not configured correctly"
});
}
return Ok(new
{
success = true,
trackId = result.SpotifyTrackId,
syncType = result.SyncType,
lineCount = result.Lines.Count,
language = result.Language,
provider = result.Provider,
providerDisplayName = result.ProviderDisplayName,
lines = result.Lines.Select(l => new
{
startTimeMs = l.StartTimeMs,
endTimeMs = l.EndTimeMs,
words = l.Words
}).ToList(),
// Also show LRC format
lrcFormat = string.Join("\n", result.Lines.Select(l =>
{
var timestamp = TimeSpan.FromMilliseconds(l.StartTimeMs);
var mm = (int)timestamp.TotalMinutes;
var ss = timestamp.Seconds;
var ms = timestamp.Milliseconds / 10;
return $"[{mm:D2}:{ss:D2}.{ms:D2}]{l.Words}";
}))
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Spotify lyrics for track {TrackId}", trackId);
return StatusCode(500, new { error = $"Failed to fetch lyrics: {ex.Message}" });
}
}
/// <summary> /// <summary>
/// Prefetch lyrics for a specific playlist /// Prefetch lyrics for a specific playlist
/// </summary> /// </summary>
@@ -2932,6 +3094,30 @@ public class AdminController : ControllerBase
} }
#endregion #endregion
#region Helper Methods
/// <summary>
/// Invalidates the cached playlist summary so it will be regenerated on next request
/// </summary>
private void InvalidatePlaylistSummaryCache()
{
try
{
var cacheFile = "/app/cache/admin_playlists_summary.json";
if (System.IO.File.Exists(cacheFile))
{
System.IO.File.Delete(cacheFile);
_logger.LogDebug("🗑️ Invalidated playlist summary cache");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to invalidate playlist summary cache");
}
}
#endregion
} }
public class ManualMappingRequest public class ManualMappingRequest

View File

@@ -1150,7 +1150,18 @@ public class JellyfinController : ControllerBase
if (isExternal) if (isExternal)
{ {
song = await _metadataService.GetSongAsync(provider!, externalId!); song = await _metadataService.GetSongAsync(provider!, externalId!);
// For Deezer tracks, we'll search Spotify by metadata
// Try to find Spotify ID from matched tracks cache
// External tracks from playlists should have been matched and cached
if (song != null)
{
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
if (!string.IsNullOrEmpty(spotifyTrackId))
{
_logger.LogInformation("Found Spotify ID {SpotifyId} for external track {Provider}/{ExternalId}",
spotifyTrackId, provider, externalId);
}
}
} }
else else
{ {
@@ -1197,27 +1208,20 @@ public class JellyfinController : ControllerBase
LyricsInfo? lyrics = null; LyricsInfo? lyrics = null;
// Try Spotify lyrics first (better synced lyrics quality) // Try Spotify lyrics ONLY if we have a valid Spotify track ID
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled) // Spotify lyrics only work for tracks from injected playlists that have been matched
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
{ {
_logger.LogInformation("Trying Spotify lyrics for: {Artist} - {Title}", searchArtist, searchTitle); // Validate that this is a real Spotify ID (not spotify:local or other invalid formats)
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
SpotifyLyricsResult? spotifyLyrics = null; // Spotify track IDs are 22 characters, base62 encoded
if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local"))
{
_logger.LogInformation("Trying Spotify lyrics for track ID: {SpotifyId} ({Artist} - {Title})",
cleanSpotifyId, searchArtist, searchTitle);
// If we have a Spotify track ID, use it directly var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId);
if (!string.IsNullOrEmpty(spotifyTrackId))
{
spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(spotifyTrackId);
}
else
{
// Search by metadata (without [S] tags)
spotifyLyrics = await _spotifyLyricsService.SearchAndGetLyricsAsync(
searchTitle,
searchArtists.Count > 0 ? searchArtists[0] : searchArtist,
searchAlbum,
song.Duration.HasValue ? song.Duration.Value * 1000 : null);
}
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0) if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
{ {
@@ -1225,6 +1229,15 @@ public class JellyfinController : ControllerBase
searchArtist, searchTitle, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType); searchArtist, searchTitle, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics); lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
} }
else
{
_logger.LogDebug("No Spotify lyrics found for track ID {SpotifyId}", cleanSpotifyId);
}
}
else
{
_logger.LogDebug("Invalid Spotify ID format: {SpotifyId}, skipping Spotify lyrics", spotifyTrackId);
}
} }
// Fall back to LRCLIB if no Spotify lyrics // Fall back to LRCLIB if no Spotify lyrics
@@ -1344,6 +1357,116 @@ public class JellyfinController : ControllerBase
return Ok(response); return Ok(response);
} }
/// <summary>
/// Proactively fetches and caches lyrics for a track in the background.
/// Called when playback starts to ensure lyrics are ready when requested.
/// </summary>
private async Task PrefetchLyricsForTrackAsync(string itemId, bool isExternal, string? provider, string? externalId)
{
try
{
Song? song = null;
string? spotifyTrackId = null;
if (isExternal && !string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
{
// Get external track metadata
song = await _metadataService.GetSongAsync(provider, externalId);
// Try to find Spotify ID from matched tracks cache
if (song != null)
{
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
}
}
else
{
// Get local track metadata from Jellyfin
var (item, _) = await _proxyService.GetItemAsync(itemId, Request.Headers);
if (item != null && item.RootElement.TryGetProperty("Type", out var typeEl) &&
typeEl.GetString() == "Audio")
{
song = new Song
{
Title = item.RootElement.TryGetProperty("Name", out var name) ? name.GetString() ?? "" : "",
Artist = item.RootElement.TryGetProperty("AlbumArtist", out var artist) ? artist.GetString() ?? "" : "",
Album = item.RootElement.TryGetProperty("Album", out var album) ? album.GetString() ?? "" : "",
Duration = item.RootElement.TryGetProperty("RunTimeTicks", out var ticks) ? (int)(ticks.GetInt64() / 10000000) : 0
};
// Check for Spotify ID in provider IDs
if (item.RootElement.TryGetProperty("ProviderIds", out var providerIds))
{
if (providerIds.TryGetProperty("Spotify", out var spotifyId))
{
spotifyTrackId = spotifyId.GetString();
}
}
}
}
if (song == null)
{
_logger.LogDebug("Could not get song metadata for lyrics prefetch: {ItemId}", itemId);
return;
}
// Strip [S] suffix for lyrics search
var searchTitle = song.Title.Replace(" [S]", "").Trim();
var searchArtist = song.Artist?.Replace(" [S]", "").Trim() ?? "";
var searchAlbum = song.Album?.Replace(" [S]", "").Trim() ?? "";
var searchArtists = song.Artists.Select(a => a.Replace(" [S]", "").Trim()).ToList();
if (searchArtists.Count == 0 && !string.IsNullOrEmpty(searchArtist))
{
searchArtists.Add(searchArtist);
}
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Title}", searchArtist, searchTitle);
// Try Spotify lyrics if we have a valid Spotify track ID
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
{
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local"))
{
var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId);
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
{
_logger.LogDebug("✓ Prefetched Spotify lyrics for {Artist} - {Title} ({LineCount} lines)",
searchArtist, searchTitle, spotifyLyrics.Lines.Count);
return; // Success, lyrics are now cached
}
}
}
// Fall back to LRCLIB
if (_lrclibService != null)
{
var lyrics = await _lrclibService.GetLyricsAsync(
searchTitle,
searchArtists.ToArray(),
searchAlbum,
song.Duration ?? 0);
if (lyrics != null)
{
_logger.LogDebug("✓ Prefetched LRCLIB lyrics for {Artist} - {Title}", searchArtist, searchTitle);
}
else
{
_logger.LogDebug("No lyrics found for {Artist} - {Title}", searchArtist, searchTitle);
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error prefetching lyrics for track {ItemId}", itemId);
}
}
#endregion #endregion
#region Favorites #region Favorites
@@ -1610,6 +1733,16 @@ public class JellyfinController : ControllerBase
{ {
try try
{ {
// Check cache first (1 hour TTL for playlist images since they can change)
var cacheKey = $"playlist:image:{playlistId}";
var cachedImage = await _cache.GetAsync<byte[]>(cacheKey);
if (cachedImage != null)
{
_logger.LogDebug("Serving cached playlist image for {PlaylistId}", playlistId);
return File(cachedImage, "image/jpeg");
}
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId); var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
var playlist = await _metadataService.GetPlaylistAsync(provider, externalId); var playlist = await _metadataService.GetPlaylistAsync(provider, externalId);
@@ -1626,6 +1759,11 @@ public class JellyfinController : ControllerBase
var imageBytes = await response.Content.ReadAsByteArrayAsync(); var imageBytes = await response.Content.ReadAsByteArrayAsync();
var contentType = response.Content.Headers.ContentType?.ToString() ?? "image/jpeg"; var contentType = response.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
// Cache for 1 hour (playlists can change, so don't cache too long)
await _cache.SetAsync(cacheKey, imageBytes, TimeSpan.FromHours(1));
_logger.LogDebug("Cached playlist image for {PlaylistId}", playlistId);
return File(imageBytes, contentType); return File(imageBytes, contentType);
} }
catch (Exception ex) catch (Exception ex)
@@ -2038,6 +2176,20 @@ public class JellyfinController : ControllerBase
{ {
_logger.LogInformation("🎵 External track playback started: {Name} ({Provider}/{ExternalId})", _logger.LogInformation("🎵 External track playback started: {Name} ({Provider}/{ExternalId})",
itemName ?? "Unknown", provider, externalId); itemName ?? "Unknown", provider, externalId);
// Proactively fetch lyrics in background for external tracks
_ = Task.Run(async () =>
{
try
{
await PrefetchLyricsForTrackAsync(itemId, isExternal: true, provider, externalId);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to prefetch lyrics for external track {ItemId}", itemId);
}
});
// For external tracks, we can't report to Jellyfin since it doesn't know about them // For external tracks, we can't report to Jellyfin since it doesn't know about them
// Just return success so the client is happy // Just return success so the client is happy
return NoContent(); return NoContent();
@@ -2045,6 +2197,19 @@ public class JellyfinController : ControllerBase
_logger.LogInformation("🎵 Local track playback started: {Name} (ID: {ItemId})", _logger.LogInformation("🎵 Local track playback started: {Name} (ID: {ItemId})",
itemName ?? "Unknown", itemId); itemName ?? "Unknown", itemId);
// Proactively fetch lyrics in background for local tracks
_ = Task.Run(async () =>
{
try
{
await PrefetchLyricsForTrackAsync(itemId, isExternal: false, null, null);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to prefetch lyrics for local track {ItemId}", itemId);
}
});
} }
// For local tracks, forward playback start to Jellyfin FIRST // For local tracks, forward playback start to Jellyfin FIRST
@@ -3105,6 +3270,14 @@ public class JellyfinController : ControllerBase
_logger.LogInformation("✅ Loaded {Count} playlist items from Redis cache for {Playlist}", _logger.LogInformation("✅ Loaded {Count} playlist items from Redis cache for {Playlist}",
cachedItems.Count, spotifyPlaylistName); cachedItems.Count, spotifyPlaylistName);
// Log sample item to verify Spotify IDs are present
if (cachedItems.Count > 0 && cachedItems[0].ContainsKey("ProviderIds"))
{
var providerIds = cachedItems[0]["ProviderIds"] as Dictionary<string, object>;
var hasSpotifyId = providerIds?.ContainsKey("Spotify") ?? false;
_logger.LogDebug("Sample cached item has Spotify ID: {HasSpotifyId}", hasSpotifyId);
}
return new JsonResult(new return new JsonResult(new
{ {
Items = cachedItems, Items = cachedItems,
@@ -3276,11 +3449,27 @@ public class JellyfinController : ControllerBase
{ {
// Convert external song to Jellyfin item format // Convert external song to Jellyfin item format
var externalItem = _responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong); var externalItem = _responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong);
// Add Spotify ID to ProviderIds so lyrics can work
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
{
if (!externalItem.ContainsKey("ProviderIds"))
{
externalItem["ProviderIds"] = new Dictionary<string, string>();
}
var providerIds = externalItem["ProviderIds"] as Dictionary<string, string>;
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
{
providerIds["Spotify"] = spotifyTrack.SpotifyId;
}
}
finalItems.Add(externalItem); finalItems.Add(externalItem);
externalUsedCount++; externalUsedCount++;
_logger.LogDebug("📥 Position #{Pos}: '{Title}' → EXTERNAL: {Provider}/{Id}", _logger.LogDebug("📥 Position #{Pos}: '{Title}' → EXTERNAL: {Provider}/{Id} (Spotify ID: {SpotifyId})",
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.Position, spotifyTrack.Title,
matched.MatchedSong.ExternalProvider, matched.MatchedSong.ExternalId); matched.MatchedSong.ExternalProvider, matched.MatchedSong.ExternalId, spotifyTrack.SpotifyId);
} }
else else
{ {
@@ -4122,5 +4311,51 @@ public class JellyfinController : ControllerBase
return (deviceId, client, device, version); return (deviceId, client, device, version);
} }
/// <summary>
/// Finds the Spotify ID for an external track by searching through all playlist matched tracks caches.
/// This allows us to get Spotify lyrics for external tracks that were matched from Spotify playlists.
/// </summary>
private async Task<string?> FindSpotifyIdForExternalTrackAsync(Song externalSong)
{
try
{
// Get all configured playlists
var playlists = _spotifySettings.Playlists;
// Search through each playlist's matched tracks cache
foreach (var playlist in playlists)
{
var cacheKey = $"spotify:matched:ordered:{playlist.Name}";
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(cacheKey);
if (matchedTracks == null || matchedTracks.Count == 0)
continue;
// Look for a match by external ID
var match = matchedTracks.FirstOrDefault(t =>
t.MatchedSong != null &&
t.MatchedSong.ExternalProvider == externalSong.ExternalProvider &&
t.MatchedSong.ExternalId == externalSong.ExternalId);
if (match != null && !string.IsNullOrEmpty(match.SpotifyId))
{
_logger.LogDebug("Found Spotify ID {SpotifyId} for {Provider}/{ExternalId} in playlist {Playlist}",
match.SpotifyId, externalSong.ExternalProvider, externalSong.ExternalId, playlist.Name);
return match.SpotifyId;
}
}
_logger.LogDebug("No Spotify ID found for external track {Provider}/{ExternalId}",
externalSong.ExternalProvider, externalSong.ExternalId);
return null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error finding Spotify ID for external track");
return null;
}
}
} }
// force rebuild Sun Jan 25 13:22:47 EST 2026 // force rebuild Sun Jan 25 13:22:47 EST 2026

View File

@@ -28,6 +28,7 @@ public class SubsonicController : ControllerBase
private readonly SubsonicModelMapper _modelMapper; private readonly SubsonicModelMapper _modelMapper;
private readonly SubsonicProxyService _proxyService; private readonly SubsonicProxyService _proxyService;
private readonly PlaylistSyncService? _playlistSyncService; private readonly PlaylistSyncService? _playlistSyncService;
private readonly RedisCacheService _cache;
private readonly ILogger<SubsonicController> _logger; private readonly ILogger<SubsonicController> _logger;
public SubsonicController( public SubsonicController(
@@ -39,6 +40,7 @@ public class SubsonicController : ControllerBase
SubsonicResponseBuilder responseBuilder, SubsonicResponseBuilder responseBuilder,
SubsonicModelMapper modelMapper, SubsonicModelMapper modelMapper,
SubsonicProxyService proxyService, SubsonicProxyService proxyService,
RedisCacheService cache,
ILogger<SubsonicController> logger, ILogger<SubsonicController> logger,
PlaylistSyncService? playlistSyncService = null) PlaylistSyncService? playlistSyncService = null)
{ {
@@ -51,6 +53,7 @@ public class SubsonicController : ControllerBase
_modelMapper = modelMapper; _modelMapper = modelMapper;
_proxyService = proxyService; _proxyService = proxyService;
_playlistSyncService = playlistSyncService; _playlistSyncService = playlistSyncService;
_cache = cache;
_logger = logger; _logger = logger;
if (string.IsNullOrWhiteSpace(_subsonicSettings.Url)) if (string.IsNullOrWhiteSpace(_subsonicSettings.Url))
@@ -559,6 +562,16 @@ public class SubsonicController : ControllerBase
{ {
try try
{ {
// Check cache first (1 hour TTL for playlist images since they can change)
var cacheKey = $"playlist:image:{id}";
var cachedImage = await _cache.GetAsync<byte[]>(cacheKey);
if (cachedImage != null)
{
_logger.LogDebug("Serving cached playlist cover art for {Id}", id);
return File(cachedImage, "image/jpeg");
}
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id); var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id);
var playlist = await _metadataService.GetPlaylistAsync(provider, externalId); var playlist = await _metadataService.GetPlaylistAsync(provider, externalId);
@@ -576,6 +589,11 @@ public class SubsonicController : ControllerBase
var imageBytes = await imageResponse.Content.ReadAsByteArrayAsync(); var imageBytes = await imageResponse.Content.ReadAsByteArrayAsync();
var contentType = imageResponse.Content.Headers.ContentType?.ToString() ?? "image/jpeg"; var contentType = imageResponse.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
// Cache for 1 hour (playlists can change, so don't cache too long)
await _cache.SetAsync(cacheKey, imageBytes, TimeSpan.FromHours(1));
_logger.LogDebug("Cached playlist cover art for {Id}", id);
return File(imageBytes, contentType); return File(imageBytes, contentType);
} }
catch (Exception ex) catch (Exception ex)

View File

@@ -124,6 +124,42 @@ public class LyricsPrefetchService : BackgroundService
return (0, 0, 0); return (0, 0, 0);
} }
// Get the pre-built playlist items cache which includes Jellyfin item IDs for local tracks
var playlistItemsKey = $"spotify:playlist:items:{playlistName}";
var playlistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsKey);
// Build a map of Spotify ID -> Jellyfin Item ID for quick lookup
var spotifyToJellyfinId = new Dictionary<string, string>();
if (playlistItems != null)
{
foreach (var item in playlistItems)
{
// Check if this is a local Jellyfin track (has Id field, no ProviderIds for external)
if (item.TryGetValue("Id", out var idObj) && idObj != null)
{
var jellyfinId = idObj.ToString();
// Try to get Spotify provider ID
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
{
var providerIdsJson = JsonSerializer.Serialize(providerIdsObj);
using var doc = JsonDocument.Parse(providerIdsJson);
if (doc.RootElement.TryGetProperty("Spotify", out var spotifyIdEl))
{
var spotifyId = spotifyIdEl.GetString();
if (!string.IsNullOrEmpty(spotifyId) && !string.IsNullOrEmpty(jellyfinId))
{
spotifyToJellyfinId[spotifyId] = jellyfinId;
}
}
}
}
}
_logger.LogDebug("Found {Count} local Jellyfin tracks with Spotify IDs in playlist {Playlist}",
spotifyToJellyfinId.Count, playlistName);
}
var fetched = 0; var fetched = 0;
var cached = 0; var cached = 0;
var missing = 0; var missing = 0;
@@ -147,12 +183,15 @@ public class LyricsPrefetchService : BackgroundService
continue; continue;
} }
// Check if this track has local Jellyfin lyrics (embedded in file) // Priority 1: Check if this track has local Jellyfin lyrics (embedded in file)
var hasLocalLyrics = await CheckForLocalJellyfinLyricsAsync(track.SpotifyId); // Use the Jellyfin item ID from the playlist cache if available
if (spotifyToJellyfinId.TryGetValue(track.SpotifyId, out var jellyfinItemId))
{
var hasLocalLyrics = await CheckForLocalJellyfinLyricsByIdAsync(jellyfinItemId, track.PrimaryArtist, track.Title);
if (hasLocalLyrics) if (hasLocalLyrics)
{ {
cached++; cached++;
_logger.LogInformation("✓ Local Jellyfin lyrics found for {Artist} - {Track}, skipping LRCLib fetch", _logger.LogInformation("✓ Local Jellyfin lyrics found for {Artist} - {Track}, skipping external fetch",
track.PrimaryArtist, track.Title); track.PrimaryArtist, track.Title);
// Remove any previously cached LRCLib lyrics for this track // Remove any previously cached LRCLib lyrics for this track
@@ -160,15 +199,16 @@ public class LyricsPrefetchService : BackgroundService
await RemoveCachedLyricsAsync(artistNameForRemoval, track.Title, track.Album, track.DurationMs / 1000); await RemoveCachedLyricsAsync(artistNameForRemoval, track.Title, track.Album, track.DurationMs / 1000);
continue; continue;
} }
}
// Try Spotify lyrics first if we have a Spotify ID // Priority 2: Try Spotify lyrics if we have a Spotify ID
LyricsInfo? lyrics = null; LyricsInfo? lyrics = null;
if (!string.IsNullOrEmpty(track.SpotifyId)) if (!string.IsNullOrEmpty(track.SpotifyId))
{ {
lyrics = await TryGetSpotifyLyricsAsync(track.SpotifyId, track.Title, track.PrimaryArtist); lyrics = await TryGetSpotifyLyricsAsync(track.SpotifyId, track.Title, track.PrimaryArtist);
} }
// Fall back to LRCLib if no Spotify lyrics // Priority 3: Fall back to LRCLib if no Spotify lyrics
if (lyrics == null) if (lyrics == null)
{ {
lyrics = await _lrclibService.GetLyricsAsync( lyrics = await _lrclibService.GetLyricsAsync(
@@ -350,10 +390,10 @@ public class LyricsPrefetchService : BackgroundService
} }
/// <summary> /// <summary>
/// Checks if a track has embedded lyrics in Jellyfin by querying the Jellyfin API. /// Checks if a track has embedded lyrics in Jellyfin using the Jellyfin item ID.
/// This prevents downloading lyrics from LRCLib when the local file already has them. /// This is the most efficient method as it directly queries the lyrics endpoint.
/// </summary> /// </summary>
private async Task<bool> CheckForLocalJellyfinLyricsAsync(string spotifyTrackId) private async Task<bool> CheckForLocalJellyfinLyricsByIdAsync(string jellyfinItemId, string artistName, string trackTitle)
{ {
try try
{ {
@@ -365,13 +405,54 @@ public class LyricsPrefetchService : BackgroundService
return false; return false;
} }
// Search for the track in Jellyfin by Spotify provider ID // Directly check if this track has lyrics using the item ID
var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsync(
$"Audio/{jellyfinItemId}/Lyrics",
null,
null);
if (lyricsResult != null && lyricsStatusCode == 200)
{
// Track has embedded lyrics in Jellyfin
_logger.LogDebug("Found embedded lyrics in Jellyfin for {Artist} - {Track} (ID: {JellyfinId})",
artistName, trackTitle, jellyfinItemId);
return true;
}
return false;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error checking Jellyfin lyrics for item {ItemId}", jellyfinItemId);
return false;
}
}
/// <summary>
/// Checks if a track has embedded lyrics in Jellyfin by querying the Jellyfin API.
/// This prevents downloading lyrics from LRCLib when the local file already has them.
/// </summary>
private async Task<bool> CheckForLocalJellyfinLyricsAsync(string spotifyTrackId, string artistName, string trackTitle)
{
try
{
using var scope = _serviceProvider.CreateScope();
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
if (proxyService == null)
{
return false;
}
// Search for the track in Jellyfin by artist and title
// Jellyfin doesn't support anyProviderIdEquals - that's an Emby API parameter
var searchTerm = $"{artistName} {trackTitle}";
var searchParams = new Dictionary<string, string> var searchParams = new Dictionary<string, string>
{ {
["anyProviderIdEquals"] = $"Spotify.{spotifyTrackId}", ["searchTerm"] = searchTerm,
["includeItemTypes"] = "Audio", ["includeItemTypes"] = "Audio",
["recursive"] = "true", ["recursive"] = "true",
["limit"] = "1" ["limit"] = "5" // Get a few results to find best match
}; };
var (searchResult, statusCode) = await proxyService.GetJsonAsync("Items", searchParams, null); var (searchResult, statusCode) = await proxyService.GetJsonAsync("Items", searchParams, null);
@@ -389,30 +470,57 @@ public class LyricsPrefetchService : BackgroundService
return false; return false;
} }
// Get the first matching track's ID // Find the best matching track by comparing artist and title
var firstItem = items[0]; string? bestMatchId = null;
if (!firstItem.TryGetProperty("Id", out var idElement)) foreach (var item in items.EnumerateArray())
{ {
return false; if (!item.TryGetProperty("Name", out var nameEl) ||
!item.TryGetProperty("Id", out var idEl))
{
continue;
} }
var jellyfinTrackId = idElement.GetString(); var itemTitle = nameEl.GetString() ?? "";
if (string.IsNullOrEmpty(jellyfinTrackId)) var itemId = idEl.GetString();
// Check if title matches (case-insensitive)
if (itemTitle.Equals(trackTitle, StringComparison.OrdinalIgnoreCase))
{
// Also check artist if available
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{
var itemArtist = artistsEl[0].GetString() ?? "";
if (itemArtist.Equals(artistName, StringComparison.OrdinalIgnoreCase))
{
bestMatchId = itemId;
break; // Exact match found
}
}
// If no exact artist match but title matches, use it as fallback
if (bestMatchId == null)
{
bestMatchId = itemId;
}
}
}
if (string.IsNullOrEmpty(bestMatchId))
{ {
return false; return false;
} }
// Check if this track has lyrics // Check if this track has lyrics
var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsync( var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsync(
$"Audio/{jellyfinTrackId}/Lyrics", $"Audio/{bestMatchId}/Lyrics",
null, null,
null); null);
if (lyricsResult != null && lyricsStatusCode == 200) if (lyricsResult != null && lyricsStatusCode == 200)
{ {
// Track has embedded lyrics in Jellyfin // Track has embedded lyrics in Jellyfin
_logger.LogDebug("Found embedded lyrics in Jellyfin for Spotify track {SpotifyId} (Jellyfin ID: {JellyfinId})", _logger.LogDebug("Found embedded lyrics in Jellyfin for {Artist} - {Track} (Jellyfin ID: {JellyfinId})",
spotifyTrackId, jellyfinTrackId); artistName, trackTitle, bestMatchId);
return true; return true;
} }

View File

@@ -805,50 +805,16 @@ public class SpotifyTrackMatchingService : BackgroundService
var usedJellyfinItems = new HashSet<string>(); var usedJellyfinItems = new HashSet<string>();
var localUsedCount = 0; var localUsedCount = 0;
var externalUsedCount = 0; var externalUsedCount = 0;
var manualLocalCount = 0;
var manualExternalCount = 0; var manualExternalCount = 0;
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position)) foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
{ {
if (cancellationToken.IsCancellationRequested) break; if (cancellationToken.IsCancellationRequested) break;
// FIRST: Check for manual mapping
var manualMappingKey = $"spotify:manual-map:{playlistName}:{spotifyTrack.SpotifyId}";
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
JsonElement? matchedJellyfinItem = null; JsonElement? matchedJellyfinItem = null;
string? matchedKey = null; string? matchedKey = null;
if (!string.IsNullOrEmpty(manualJellyfinId)) // Check for external manual mapping
{
// Manual Jellyfin mapping exists - fetch the Jellyfin item by ID
try
{
var itemUrl = $"Items/{manualJellyfinId}?UserId={userId}";
var (itemResponse, itemStatusCode) = await proxyService.GetJsonAsync(itemUrl, null, headers);
if (itemStatusCode == 200 && itemResponse != null)
{
matchedJellyfinItem = itemResponse.RootElement;
manualLocalCount++;
_logger.LogDebug("✓ Using manual Jellyfin mapping for {Title}: Jellyfin ID {Id}",
spotifyTrack.Title, manualJellyfinId);
}
else
{
_logger.LogWarning("Manual Jellyfin mapping points to invalid Jellyfin ID {Id} for {Title}",
manualJellyfinId, spotifyTrack.Title);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch manually mapped Jellyfin item {Id}", manualJellyfinId);
}
}
// Check for external manual mapping if no Jellyfin mapping found
if (!matchedJellyfinItem.HasValue)
{
var externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}"; var externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}";
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey); var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
@@ -928,6 +894,22 @@ public class SpotifyTrackMatchingService : BackgroundService
// Convert external song to Jellyfin item format and add to finalItems // Convert external song to Jellyfin item format and add to finalItems
var externalItem = responseBuilder.ConvertSongToJellyfinItem(externalSong); var externalItem = responseBuilder.ConvertSongToJellyfinItem(externalSong);
// Add Spotify ID to ProviderIds so lyrics can work
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
{
if (!externalItem.ContainsKey("ProviderIds"))
{
externalItem["ProviderIds"] = new Dictionary<string, string>();
}
var providerIds = externalItem["ProviderIds"] as Dictionary<string, string>;
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
{
providerIds["Spotify"] = spotifyTrack.SpotifyId;
}
}
finalItems.Add(externalItem); finalItems.Add(externalItem);
externalUsedCount++; externalUsedCount++;
manualExternalCount++; manualExternalCount++;
@@ -942,11 +924,8 @@ public class SpotifyTrackMatchingService : BackgroundService
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", spotifyTrack.Title); _logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", spotifyTrack.Title);
} }
} }
}
// SECOND: If no manual mapping, try fuzzy matching // If no manual external mapping, try fuzzy matching with local Jellyfin tracks
if (!matchedJellyfinItem.HasValue)
{
double bestScore = 0; double bestScore = 0;
foreach (var kvp in jellyfinItemsByName) foreach (var kvp in jellyfinItemsByName)
@@ -972,7 +951,6 @@ public class SpotifyTrackMatchingService : BackgroundService
matchedKey = kvp.Key; matchedKey = kvp.Key;
} }
} }
}
if (matchedJellyfinItem.HasValue) if (matchedJellyfinItem.HasValue)
{ {
@@ -996,6 +974,22 @@ public class SpotifyTrackMatchingService : BackgroundService
{ {
// Convert external song to Jellyfin item format // Convert external song to Jellyfin item format
var externalItem = responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong); var externalItem = responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong);
// Add Spotify ID to ProviderIds so lyrics can work
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
{
if (!externalItem.ContainsKey("ProviderIds"))
{
externalItem["ProviderIds"] = new Dictionary<string, string>();
}
var providerIds = externalItem["ProviderIds"] as Dictionary<string, string>;
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
{
providerIds["Spotify"] = spotifyTrack.SpotifyId;
}
}
finalItems.Add(externalItem); finalItems.Add(externalItem);
externalUsedCount++; externalUsedCount++;
} }
@@ -1012,9 +1006,9 @@ public class SpotifyTrackMatchingService : BackgroundService
await SavePlaylistItemsToFileAsync(playlistName, finalItems); await SavePlaylistItemsToFileAsync(playlistName, finalItems);
var manualMappingInfo = ""; var manualMappingInfo = "";
if (manualLocalCount > 0 || manualExternalCount > 0) if (manualExternalCount > 0)
{ {
manualMappingInfo = $" [Manual: {manualLocalCount} local, {manualExternalCount} external]"; manualMappingInfo = $" [Manual external: {manualExternalCount}]";
} }
_logger.LogInformation( _logger.LogInformation(

View File

@@ -676,6 +676,48 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Manual Track Mappings Section -->
<div class="card">
<h2>
Manual Track Mappings
<div class="actions">
<button onclick="fetchTrackMappings()">Refresh</button>
</div>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 12px;">
Manual mappings override automatic matching for external providers (SquidWTF, Deezer, Qobuz). For local Jellyfin tracks, use the Spotify Import plugin instead.
</p>
<div id="mappings-summary" style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
<div>
<span style="color: var(--text-secondary);">Total:</span>
<span style="font-weight: 600; margin-left: 8px;" id="mappings-total">0</span>
</div>
<div>
<span style="color: var(--text-secondary);">External:</span>
<span style="font-weight: 600; margin-left: 8px; color: var(--success);" id="mappings-external">0</span>
</div>
</div>
<table class="playlist-table">
<thead>
<tr>
<th>Playlist</th>
<th>Spotify ID</th>
<th>Type</th>
<th>Target</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="mappings-table-body">
<tr>
<td colspan="6" class="loading">
<span class="spinner"></span> Loading mappings...
</td>
</tr>
</tbody>
</table>
</div>
</div> </div>
<!-- Configuration Tab --> <!-- Configuration Tab -->
@@ -896,9 +938,9 @@
<!-- Manual Track Mapping Modal --> <!-- Manual Track Mapping Modal -->
<div class="modal" id="manual-map-modal"> <div class="modal" id="manual-map-modal">
<div class="modal-content" style="max-width: 600px;"> <div class="modal-content" style="max-width: 600px;">
<h3>Map Track</h3> <h3>Map Track to External Provider</h3>
<p style="color: var(--text-secondary); margin-bottom: 16px;"> <p style="color: var(--text-secondary); margin-bottom: 16px;">
Map this track to either a local Jellyfin track or provide an external provider ID. Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Spotify Import plugin instead.
</p> </p>
<!-- Track Info --> <!-- Track Info -->
@@ -910,41 +952,8 @@
</div> </div>
</div> </div>
<!-- Mapping Type Selection -->
<div class="form-group">
<label>Mapping Type</label>
<select id="map-type-select" onchange="toggleMappingType()" style="width: 100%;">
<option value="jellyfin">Map to Local Jellyfin Track</option>
<option value="external">Map to External Provider ID</option>
</select>
</div>
<!-- Jellyfin Mapping Section -->
<div id="jellyfin-mapping-section">
<div class="form-group">
<label>Search Jellyfin Tracks</label>
<input type="text" id="map-search-query" placeholder="Search by title, artist, or album..." oninput="searchJellyfinTracks()">
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Tip: Use commas to search multiple terms (e.g., "It Ain't Easy, David Bowie")
</small>
</div>
<div style="text-align: center; color: var(--text-secondary); margin: 12px 0;">— OR —</div>
<div class="form-group">
<label>Paste Jellyfin Track URL</label>
<input type="text" id="map-jellyfin-url" placeholder="https://jellyfin.example.com/web/#/details?id=..." oninput="extractJellyfinId()">
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Paste the full URL from your Jellyfin web interface
</small>
</div>
<div id="map-search-results" style="max-height: 300px; overflow-y: auto; margin-top: 12px;">
<p style="text-align: center; color: var(--text-secondary); padding: 20px;">
Type to search for local tracks or paste a Jellyfin URL...
</p>
</div>
</div>
<!-- External Mapping Section --> <!-- External Mapping Section -->
<div id="external-mapping-section" style="display: none;"> <div id="external-mapping-section">
<div class="form-group"> <div class="form-group">
<label>External Provider</label> <label>External Provider</label>
<select id="map-external-provider" style="width: 100%;"> <select id="map-external-provider" style="width: 100%;">
@@ -966,7 +975,6 @@
<input type="hidden" id="map-playlist-name"> <input type="hidden" id="map-playlist-name">
<input type="hidden" id="map-spotify-id"> <input type="hidden" id="map-spotify-id">
<input type="hidden" id="map-selected-jellyfin-id">
<div class="modal-actions"> <div class="modal-actions">
<button onclick="closeModal('manual-map-modal')">Cancel</button> <button onclick="closeModal('manual-map-modal')">Cancel</button>
<button class="primary" onclick="saveManualMapping()" id="map-save-btn" disabled>Save Mapping</button> <button class="primary" onclick="saveManualMapping()" id="map-save-btn" disabled>Save Mapping</button>
@@ -1337,6 +1345,89 @@
} }
} }
async function fetchTrackMappings() {
try {
const res = await fetch('/api/admin/mappings/tracks');
const data = await res.json();
// Update summary (only external now)
document.getElementById('mappings-total').textContent = data.externalCount || 0;
document.getElementById('mappings-external').textContent = data.externalCount || 0;
const tbody = document.getElementById('mappings-table-body');
if (data.mappings.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No manual mappings found.</td></tr>';
return;
}
// Filter to only show external mappings
const externalMappings = data.mappings.filter(m => m.type === 'external');
if (externalMappings.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No external mappings found. Local Jellyfin mappings should be managed via Spotify Import plugin.</td></tr>';
return;
}
tbody.innerHTML = externalMappings.map((m, index) => {
const typeColor = 'var(--success)';
const typeBadge = `<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.8rem;background:${typeColor}20;color:${typeColor};font-weight:500;">external</span>`;
const targetDisplay = `<span style="font-family:monospace;font-size:0.85rem;color:var(--success);">${m.externalProvider}/${m.externalId}</span>`;
const createdDate = m.createdAt ? new Date(m.createdAt).toLocaleString() : '-';
return `
<tr>
<td><strong>${escapeHtml(m.playlist)}</strong></td>
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${m.spotifyId}</td>
<td>${typeBadge}</td>
<td>${targetDisplay}</td>
<td style="color:var(--text-secondary);font-size:0.85rem;">${createdDate}</td>
<td>
<button class="danger delete-mapping-btn" style="padding:4px 12px;font-size:0.8rem;" data-playlist="${escapeHtml(m.playlist)}" data-spotify-id="${m.spotifyId}">Remove</button>
</td>
</tr>
`;
}).join('');
// Add event listeners to all delete buttons
document.querySelectorAll('.delete-mapping-btn').forEach(btn => {
btn.addEventListener('click', function() {
const playlist = this.getAttribute('data-playlist');
const spotifyId = this.getAttribute('data-spotify-id');
deleteTrackMapping(playlist, spotifyId);
});
});
} catch (error) {
console.error('Failed to fetch track mappings:', error);
showToast('Failed to fetch track mappings', 'error');
}
}
async function deleteTrackMapping(playlist, spotifyId) {
if (!confirm(`Remove manual external mapping for ${spotifyId} in playlist "${playlist}"?\n\nThis will:\n• Delete the manual mapping from the cache\n• Allow the track to be matched automatically again\n• The track may be re-matched with potentially better results\n\nThis action cannot be undone.`)) {
return;
}
try {
const res = await fetch(`/api/admin/mappings/tracks?playlist=${encodeURIComponent(playlist)}&spotifyId=${encodeURIComponent(spotifyId)}`, {
method: 'DELETE'
});
if (res.ok) {
showToast('Mapping removed successfully', 'success');
await fetchTrackMappings();
} else {
const error = await res.json();
showToast(error.error || 'Failed to remove mapping', 'error');
}
} catch (error) {
console.error('Failed to delete mapping:', error);
showToast('Failed to remove mapping', 'error');
}
}
async function fetchConfig() { async function fetchConfig() {
try { try {
const res = await fetch('/api/admin/config'); const res = await fetch('/api/admin/config');
@@ -2191,34 +2282,6 @@
document.getElementById('map-save-btn').disabled = false; document.getElementById('map-save-btn').disabled = false;
} }
// Toggle between Jellyfin and external mapping modes
function toggleMappingType() {
const mappingType = document.getElementById('map-type-select').value;
const jellyfinSection = document.getElementById('jellyfin-mapping-section');
const externalSection = document.getElementById('external-mapping-section');
const saveBtn = document.getElementById('map-save-btn');
if (mappingType === 'jellyfin') {
jellyfinSection.style.display = 'block';
externalSection.style.display = 'none';
// Reset external fields
document.getElementById('map-external-id').value = '';
// Check if Jellyfin track is selected
const jellyfinId = document.getElementById('map-selected-jellyfin-id').value;
saveBtn.disabled = !jellyfinId;
} else {
jellyfinSection.style.display = 'none';
externalSection.style.display = 'block';
// Reset Jellyfin fields
document.getElementById('map-search-query').value = '';
document.getElementById('map-jellyfin-url').value = '';
document.getElementById('map-selected-jellyfin-id').value = '';
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Enter an external provider ID above</p>';
// Check if external mapping is valid
validateExternalMapping();
}
}
// Validate external mapping input // Validate external mapping input
function validateExternalMapping() { function validateExternalMapping() {
const externalId = document.getElementById('map-external-id').value.trim(); const externalId = document.getElementById('map-external-id').value.trim();
@@ -2228,7 +2291,7 @@
saveBtn.disabled = !externalId; saveBtn.disabled = !externalId;
} }
// Update the openManualMap function to reset the modal state // Open manual mapping modal (external only)
function openManualMap(playlistName, position, title, artist, spotifyId) { function openManualMap(playlistName, position, title, artist, spotifyId) {
document.getElementById('map-playlist-name').value = playlistName; document.getElementById('map-playlist-name').value = playlistName;
document.getElementById('map-position').textContent = position + 1; document.getElementById('map-position').textContent = position + 1;
@@ -2236,74 +2299,38 @@
document.getElementById('map-spotify-artist').textContent = artist; document.getElementById('map-spotify-artist').textContent = artist;
document.getElementById('map-spotify-id').value = spotifyId; document.getElementById('map-spotify-id').value = spotifyId;
// Reset to Jellyfin mapping mode // Reset fields
document.getElementById('map-type-select').value = 'jellyfin';
document.getElementById('jellyfin-mapping-section').style.display = 'block';
document.getElementById('external-mapping-section').style.display = 'none';
// Reset all fields
document.getElementById('map-search-query').value = '';
document.getElementById('map-jellyfin-url').value = '';
document.getElementById('map-selected-jellyfin-id').value = '';
document.getElementById('map-external-id').value = ''; document.getElementById('map-external-id').value = '';
document.getElementById('map-external-provider').value = 'SquidWTF'; document.getElementById('map-external-provider').value = 'SquidWTF';
document.getElementById('map-save-btn').disabled = true; document.getElementById('map-save-btn').disabled = true;
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks or paste a Jellyfin URL...</p>';
openModal('manual-map-modal'); openModal('manual-map-modal');
} }
// Open external mapping modal (pre-set to external mode) // Alias for backward compatibility
function openExternalMap(playlistName, position, title, artist, spotifyId) { function openExternalMap(playlistName, position, title, artist, spotifyId) {
document.getElementById('map-playlist-name').value = playlistName; openManualMap(playlistName, position, title, artist, spotifyId);
document.getElementById('map-position').textContent = position + 1;
document.getElementById('map-spotify-title').textContent = title;
document.getElementById('map-spotify-artist').textContent = artist;
document.getElementById('map-spotify-id').value = spotifyId;
// Set to external mapping mode
document.getElementById('map-type-select').value = 'external';
document.getElementById('jellyfin-mapping-section').style.display = 'none';
document.getElementById('external-mapping-section').style.display = 'block';
// Reset all fields
document.getElementById('map-search-query').value = '';
document.getElementById('map-jellyfin-url').value = '';
document.getElementById('map-selected-jellyfin-id').value = '';
document.getElementById('map-external-id').value = '';
document.getElementById('map-external-provider').value = 'SquidWTF';
document.getElementById('map-save-btn').disabled = true;
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Enter an external provider ID above</p>';
openModal('manual-map-modal');
} }
// Update the saveManualMapping function to handle both types // Save manual mapping (external only)
async function saveManualMapping() { async function saveManualMapping() {
const playlistName = document.getElementById('map-playlist-name').value; const playlistName = document.getElementById('map-playlist-name').value;
const spotifyId = document.getElementById('map-spotify-id').value; const spotifyId = document.getElementById('map-spotify-id').value;
const mappingType = document.getElementById('map-type-select').value;
const position = parseInt(document.getElementById('map-position').textContent) - 1; // Convert back to 0-indexed const position = parseInt(document.getElementById('map-position').textContent) - 1; // Convert back to 0-indexed
let requestBody = { spotifyId };
if (mappingType === 'jellyfin') {
const jellyfinId = document.getElementById('map-selected-jellyfin-id').value;
if (!jellyfinId) {
showToast('Please select a track', 'error');
return;
}
requestBody.jellyfinId = jellyfinId;
} else {
const externalProvider = document.getElementById('map-external-provider').value; const externalProvider = document.getElementById('map-external-provider').value;
const externalId = document.getElementById('map-external-id').value.trim(); const externalId = document.getElementById('map-external-id').value.trim();
if (!externalId) { if (!externalId) {
showToast('Please enter an external provider ID', 'error'); showToast('Please enter an external provider ID', 'error');
return; return;
} }
requestBody.externalProvider = externalProvider;
requestBody.externalId = externalId; const requestBody = {
} spotifyId,
externalProvider,
externalId
};
// Show loading state // Show loading state
const saveBtn = document.getElementById('map-save-btn'); const saveBtn = document.getElementById('map-save-btn');
@@ -2326,8 +2353,7 @@
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
const mappingTypeText = mappingType === 'jellyfin' ? 'local Jellyfin track' : `${requestBody.externalProvider} ID`; showToast(`✓ Track mapped to ${requestBody.externalProvider} - rebuilding playlist...`, 'success');
showToast(`✓ Track mapped to ${mappingTypeText} - rebuilding playlist...`, 'success');
closeModal('manual-map-modal'); closeModal('manual-map-modal');
// Show rebuilding indicator // Show rebuilding indicator
@@ -2335,27 +2361,15 @@
// Show detailed info toast after a moment // Show detailed info toast after a moment
setTimeout(() => { setTimeout(() => {
if (mappingType === 'jellyfin') {
showToast('🔄 Rebuilding playlist with your local track mapping...', 'info', 8000);
} else {
showToast(`🔄 Rebuilding playlist with your ${requestBody.externalProvider} mapping...`, 'info', 8000); showToast(`🔄 Rebuilding playlist with your ${requestBody.externalProvider} mapping...`, 'info', 8000);
}
}, 1000); }, 1000);
// Update the track in the UI without refreshing // Update the track in the UI without refreshing
const trackItem = document.querySelector(`.track-item[data-position="${position}"]`); const trackItem = document.querySelector(`.track-item[data-position="${position}"]`);
if (trackItem) { if (trackItem) {
const titleEl = trackItem.querySelector('.track-info h4'); const titleEl = trackItem.querySelector('.track-info h4');
if (titleEl && mappingType === 'jellyfin' && data.track) { if (titleEl) {
// For Jellyfin mappings, update with actual track info // Update status badge to show provider
const titleText = data.track.title;
const newStatusBadge = '<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
titleEl.innerHTML = escapeHtml(titleText) + newStatusBadge;
const artistEl = trackItem.querySelector('.track-info .artists');
if (artistEl) artistEl.textContent = data.track.artist;
} else if (titleEl && mappingType === 'external') {
// For external mappings, update status badge to show provider
const currentTitle = titleEl.textContent.split(' - ')[0]; // Remove old status const currentTitle = titleEl.textContent.split(' - ')[0]; // Remove old status
const capitalizedProvider = capitalizeProvider(requestBody.externalProvider); const capitalizedProvider = capitalizeProvider(requestBody.externalProvider);
const newStatusBadge = `<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(capitalizedProvider)}</span>`; const newStatusBadge = `<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(capitalizedProvider)}</span>`;
@@ -2500,6 +2514,7 @@
// Initial load // Initial load
fetchStatus(); fetchStatus();
fetchPlaylists(); fetchPlaylists();
fetchTrackMappings();
fetchJellyfinUsers(); fetchJellyfinUsers();
fetchJellyfinPlaylists(); fetchJellyfinPlaylists();
fetchConfig(); fetchConfig();
@@ -2508,6 +2523,7 @@
setInterval(() => { setInterval(() => {
fetchStatus(); fetchStatus();
fetchPlaylists(); fetchPlaylists();
fetchTrackMappings();
}, 30000); }, 30000);
</script> </script>
</body> </body>

View File

@@ -21,18 +21,12 @@ services:
image: akashrchandran/spotify-lyrics-api:latest image: akashrchandran/spotify-lyrics-api:latest
container_name: allstarr-spotify-lyrics container_name: allstarr-spotify-lyrics
restart: unless-stopped restart: unless-stopped
# Only accessible internally - no external port exposure ports:
expose: - "8365:8080"
- "8080"
environment: environment:
- SP_DC=${SPOTIFY_API_SESSION_COOKIE:-} - SP_DC=${SPOTIFY_API_SESSION_COOKIE:-}
networks: networks:
- allstarr-network - allstarr-network
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/"]
interval: 30s
timeout: 5s
retries: 3
allstarr: allstarr:
# Use pre-built image from GitHub Container Registry # Use pre-built image from GitHub Container Registry