mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Compare commits
17 Commits
ad5fea7d8e
...
6ccc6a4a0d
| Author | SHA1 | Date | |
|---|---|---|---|
|
6ccc6a4a0d
|
|||
|
c54503f486
|
|||
|
fbac81df64
|
|||
|
3a433e276c
|
|||
|
0c14f4a760
|
|||
|
28c4f8f5df
|
|||
|
a3830c54c4
|
|||
|
4226ead53a
|
|||
|
2155c4a9d5
|
|||
|
a56b2c3ea3
|
|||
|
810247ba8c
|
|||
|
96814aa91b
|
|||
|
d52c0fc938
|
|||
|
64eff088fa
|
|||
|
ff6dfede87
|
|||
|
d8696e254f
|
|||
|
261f20f378
|
10
.gitignore
vendored
10
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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,92 +669,76 @@ 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))
|
// Check for external manual mapping
|
||||||
|
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||||
|
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||||
{
|
{
|
||||||
// Manual Jellyfin mapping exists - this track is definitely local
|
try
|
||||||
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
|
|
||||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
|
||||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
|
||||||
{
|
{
|
||||||
try
|
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
||||||
|
var extRoot = extDoc.RootElement;
|
||||||
|
|
||||||
|
string? provider = null;
|
||||||
|
string? externalId = null;
|
||||||
|
|
||||||
|
if (extRoot.TryGetProperty("provider", out var providerEl))
|
||||||
{
|
{
|
||||||
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
provider = providerEl.GetString();
|
||||||
var extRoot = extDoc.RootElement;
|
|
||||||
|
|
||||||
string? provider = null;
|
|
||||||
string? externalId = null;
|
|
||||||
|
|
||||||
if (extRoot.TryGetProperty("provider", out var providerEl))
|
|
||||||
{
|
|
||||||
provider = providerEl.GetString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (extRoot.TryGetProperty("id", out var idEl))
|
|
||||||
{
|
|
||||||
externalId = idEl.GetString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
|
|
||||||
{
|
|
||||||
// External manual mapping exists
|
|
||||||
isLocal = false;
|
|
||||||
externalProvider = provider;
|
|
||||||
isManualMapping = true;
|
|
||||||
manualMappingType = "external";
|
|
||||||
manualMappingId = externalId;
|
|
||||||
_logger.LogDebug("✓ Manual external mapping found for {Title}: {Provider} {ExternalId}",
|
|
||||||
track.Title, provider, externalId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
|
if (extRoot.TryGetProperty("id", out var idEl))
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
|
externalId = idEl.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
|
||||||
|
{
|
||||||
|
// External manual mapping exists
|
||||||
|
isLocal = false;
|
||||||
|
externalProvider = provider;
|
||||||
|
isManualMapping = true;
|
||||||
|
manualMappingType = "external";
|
||||||
|
manualMappingId = externalId;
|
||||||
|
_logger.LogDebug("✓ Manual external mapping found for {Title}: {Provider} {ExternalId}",
|
||||||
|
track.Title, provider, externalId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (localTracks.Count > 0)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// SECOND: No manual mapping, try fuzzy matching
|
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
|
||||||
var bestMatch = localTracks
|
}
|
||||||
.Select(local => new
|
}
|
||||||
{
|
|
||||||
Local = local,
|
// If no manual mapping, try fuzzy matching with local tracks
|
||||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, local.Title),
|
if (!isManualMapping && localTracks.Count > 0)
|
||||||
ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, local.Artist)
|
{
|
||||||
})
|
var bestMatch = localTracks
|
||||||
.Select(x => new
|
.Select(local => new
|
||||||
{
|
|
||||||
x.Local,
|
|
||||||
x.TitleScore,
|
|
||||||
x.ArtistScore,
|
|
||||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
|
||||||
})
|
|
||||||
.OrderByDescending(x => x.TotalScore)
|
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
// Use 70% threshold (same as playback matching)
|
|
||||||
if (bestMatch != null && bestMatch.TotalScore >= 70)
|
|
||||||
{
|
{
|
||||||
isLocal = true;
|
Local = local,
|
||||||
}
|
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, local.Title),
|
||||||
|
ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, local.Artist)
|
||||||
|
})
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Local,
|
||||||
|
x.TitleScore,
|
||||||
|
x.ArtistScore,
|
||||||
|
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.TotalScore)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
// Use 70% threshold (same as playback matching)
|
||||||
|
if (bestMatch != null && bestMatch.TotalScore >= 70)
|
||||||
|
{
|
||||||
|
isLocal = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -782,62 +816,54 @@ 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
|
// Check for external manual mapping
|
||||||
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||||
{
|
{
|
||||||
isLocal = true;
|
try
|
||||||
|
{
|
||||||
|
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
||||||
|
var extRoot = extDoc.RootElement;
|
||||||
|
|
||||||
|
string? provider = null;
|
||||||
|
|
||||||
|
if (extRoot.TryGetProperty("provider", out var providerEl))
|
||||||
|
{
|
||||||
|
provider = providerEl.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(provider))
|
||||||
|
{
|
||||||
|
isLocal = false;
|
||||||
|
externalProvider = provider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId))
|
||||||
|
{
|
||||||
|
// Track is externally matched (search succeeded)
|
||||||
|
isLocal = false;
|
||||||
|
externalProvider = "SquidWTF"; // Default to SquidWTF for external matches
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Check for external manual mapping
|
// Track is missing (search failed)
|
||||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
isLocal = null;
|
||||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
externalProvider = null;
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
|
||||||
var extRoot = extDoc.RootElement;
|
|
||||||
|
|
||||||
string? provider = null;
|
|
||||||
|
|
||||||
if (extRoot.TryGetProperty("provider", out var providerEl))
|
|
||||||
{
|
|
||||||
provider = providerEl.GetString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(provider))
|
|
||||||
{
|
|
||||||
isLocal = false;
|
|
||||||
externalProvider = provider;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId))
|
|
||||||
{
|
|
||||||
// Track is externally matched (search succeeded)
|
|
||||||
isLocal = false;
|
|
||||||
externalProvider = "SquidWTF"; // Default to SquidWTF for external matches
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Track is missing (search failed)
|
|
||||||
isLocal = 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,48 +1100,26 @@ 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 external mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||||
{
|
var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
|
||||||
// Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
var normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
|
||||||
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
|
var externalMapping = new { provider = normalizedProvider, id = request.ExternalId };
|
||||||
await _cache.SetAsync(mappingKey, request.JellyfinId!);
|
await _cache.SetAsync(externalMappingKey, externalMapping);
|
||||||
|
|
||||||
// Also save to file for persistence across restarts
|
// Also save to file for persistence across restarts
|
||||||
await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, request.JellyfinId!, null, null);
|
await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, null, normalizedProvider, request.ExternalId!);
|
||||||
|
|
||||||
_logger.LogInformation("Manual Jellyfin mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}",
|
_logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}",
|
||||||
decodedName, request.SpotifyId, request.JellyfinId);
|
decodedName, request.SpotifyId, normalizedProvider, request.ExternalId);
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Store external mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
|
||||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
|
|
||||||
var normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
|
|
||||||
var externalMapping = new { provider = normalizedProvider, id = request.ExternalId };
|
|
||||||
await _cache.SetAsync(externalMappingKey, externalMapping);
|
|
||||||
|
|
||||||
// Also save to file for persistence across restarts
|
|
||||||
await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, null, normalizedProvider, request.ExternalId!);
|
|
||||||
|
|
||||||
_logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {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,77 +1157,32 @@ 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)
|
try
|
||||||
{
|
{
|
||||||
// Fetch Jellyfin track details
|
var metadataService = HttpContext.RequestServices.GetRequiredService<IMusicMetadataService>();
|
||||||
try
|
var externalSong = await metadataService.GetSongAsync(normalizedProvider, request.ExternalId!);
|
||||||
|
|
||||||
|
if (externalSong != null)
|
||||||
{
|
{
|
||||||
var userId = _jellyfinSettings.UserId;
|
trackTitle = externalSong.Title;
|
||||||
var trackUrl = $"{_jellyfinSettings.Url}/Items/{request.JellyfinId}";
|
trackArtist = externalSong.Artist;
|
||||||
if (!string.IsNullOrEmpty(userId))
|
trackAlbum = externalSong.Album;
|
||||||
{
|
_logger.LogInformation("✓ Fetched external track metadata: {Title} by {Artist}", trackTitle, trackArtist);
|
||||||
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)
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to fetch mapped track details, but mapping was saved");
|
_logger.LogWarning("Failed to fetch external track metadata for {Provider} ID {Id}",
|
||||||
|
normalizedProvider, request.ExternalId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Fetch external provider track details
|
_logger.LogWarning(ex, "Failed to fetch external track metadata, but mapping was saved");
|
||||||
try
|
|
||||||
{
|
|
||||||
var metadataService = HttpContext.RequestServices.GetRequiredService<IMusicMetadataService>();
|
|
||||||
var normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
|
|
||||||
var externalSong = await metadataService.GetSongAsync(normalizedProvider, request.ExternalId!);
|
|
||||||
|
|
||||||
if (externalSong != null)
|
|
||||||
{
|
|
||||||
trackTitle = externalSong.Title;
|
|
||||||
trackArtist = externalSong.Artist;
|
|
||||||
trackAlbum = externalSong.Album;
|
|
||||||
_logger.LogInformation("✓ Fetched external track metadata: {Title} by {Artist}", trackTitle, trackArtist);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Failed to fetch external track metadata for {Provider} ID {Id}",
|
|
||||||
normalizedProvider, request.ExternalId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,33 +1208,35 @@ 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"))
|
||||||
// If we have a Spotify track ID, use it directly
|
|
||||||
if (!string.IsNullOrEmpty(spotifyTrackId))
|
|
||||||
{
|
{
|
||||||
spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(spotifyTrackId);
|
_logger.LogInformation("Trying Spotify lyrics for track ID: {SpotifyId} ({Artist} - {Title})",
|
||||||
|
cleanSpotifyId, searchArtist, searchTitle);
|
||||||
|
|
||||||
|
var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId);
|
||||||
|
|
||||||
|
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Found Spotify lyrics for {Artist} - {Title} ({LineCount} lines, type: {SyncType})",
|
||||||
|
searchArtist, searchTitle, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
|
||||||
|
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No Spotify lyrics found for track ID {SpotifyId}", cleanSpotifyId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Search by metadata (without [S] tags)
|
_logger.LogDebug("Invalid Spotify ID format: {SpotifyId}, skipping Spotify lyrics", spotifyTrackId);
|
||||||
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)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Found Spotify lyrics for {Artist} - {Title} ({LineCount} lines, type: {SyncType})",
|
|
||||||
searchArtist, searchTitle, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
|
|
||||||
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1343,6 +1356,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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,28 +183,32 @@ 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 (hasLocalLyrics)
|
if (spotifyToJellyfinId.TryGetValue(track.SpotifyId, out var jellyfinItemId))
|
||||||
{
|
{
|
||||||
cached++;
|
var hasLocalLyrics = await CheckForLocalJellyfinLyricsByIdAsync(jellyfinItemId, track.PrimaryArtist, track.Title);
|
||||||
_logger.LogInformation("✓ Local Jellyfin lyrics found for {Artist} - {Track}, skipping LRCLib fetch",
|
if (hasLocalLyrics)
|
||||||
track.PrimaryArtist, track.Title);
|
{
|
||||||
|
cached++;
|
||||||
// Remove any previously cached LRCLib lyrics for this track
|
_logger.LogInformation("✓ Local Jellyfin lyrics found for {Artist} - {Track}, skipping external fetch",
|
||||||
var artistNameForRemoval = string.Join(", ", track.Artists);
|
track.PrimaryArtist, track.Title);
|
||||||
await RemoveCachedLyricsAsync(artistNameForRemoval, track.Title, track.Album, track.DurationMs / 1000);
|
|
||||||
continue;
|
// Remove any previously cached LRCLib lyrics for this track
|
||||||
|
var artistNameForRemoval = string.Join(", ", track.Artists);
|
||||||
|
await RemoveCachedLyricsAsync(artistNameForRemoval, track.Title, track.Album, track.DurationMs / 1000);
|
||||||
|
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 itemTitle = nameEl.GetString() ?? "";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var jellyfinTrackId = idElement.GetString();
|
if (string.IsNullOrEmpty(bestMatchId))
|
||||||
if (string.IsNullOrEmpty(jellyfinTrackId))
|
|
||||||
{
|
{
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -805,172 +805,150 @@ 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
|
||||||
|
var externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}";
|
||||||
|
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||||
{
|
{
|
||||||
// Manual Jellyfin mapping exists - fetch the Jellyfin item by ID
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var itemUrl = $"Items/{manualJellyfinId}?UserId={userId}";
|
using var doc = JsonDocument.Parse(externalMappingJson);
|
||||||
var (itemResponse, itemStatusCode) = await proxyService.GetJsonAsync(itemUrl, null, headers);
|
var root = doc.RootElement;
|
||||||
|
|
||||||
if (itemStatusCode == 200 && itemResponse != null)
|
string? provider = null;
|
||||||
|
string? externalId = null;
|
||||||
|
|
||||||
|
if (root.TryGetProperty("provider", out var providerEl))
|
||||||
{
|
{
|
||||||
matchedJellyfinItem = itemResponse.RootElement;
|
provider = providerEl.GetString();
|
||||||
manualLocalCount++;
|
|
||||||
_logger.LogDebug("✓ Using manual Jellyfin mapping for {Title}: Jellyfin ID {Id}",
|
|
||||||
spotifyTrack.Title, manualJellyfinId);
|
|
||||||
}
|
}
|
||||||
else
|
|
||||||
|
if (root.TryGetProperty("id", out var idEl))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Manual Jellyfin mapping points to invalid Jellyfin ID {Id} for {Title}",
|
externalId = idEl.GetString();
|
||||||
manualJellyfinId, spotifyTrack.Title);
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
|
||||||
|
{
|
||||||
|
// Fetch full metadata from the provider instead of using minimal Spotify data
|
||||||
|
Song? externalSong = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var metadataScope = _serviceProvider.CreateScope();
|
||||||
|
var metadataServiceForFetch = metadataScope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
|
||||||
|
externalSong = await metadataServiceForFetch.GetSongAsync(provider, externalId);
|
||||||
|
|
||||||
|
if (externalSong != null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("✓ Fetched full metadata for manual external mapping: {Title} by {Artist}",
|
||||||
|
externalSong.Title, externalSong.Artist);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Failed to fetch metadata for {Provider} ID {ExternalId}, using fallback",
|
||||||
|
provider, externalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Error fetching metadata for {Provider} ID {ExternalId}, using fallback",
|
||||||
|
provider, externalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to minimal metadata if fetch failed
|
||||||
|
if (externalSong == null)
|
||||||
|
{
|
||||||
|
externalSong = new Song
|
||||||
|
{
|
||||||
|
Id = $"ext-{provider}-song-{externalId}",
|
||||||
|
Title = spotifyTrack.Title,
|
||||||
|
Artist = spotifyTrack.PrimaryArtist,
|
||||||
|
Album = spotifyTrack.Album,
|
||||||
|
Duration = spotifyTrack.DurationMs / 1000,
|
||||||
|
Isrc = spotifyTrack.Isrc,
|
||||||
|
IsLocal = false,
|
||||||
|
ExternalProvider = provider,
|
||||||
|
ExternalId = externalId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var matchedTrack = new MatchedTrack
|
||||||
|
{
|
||||||
|
Position = spotifyTrack.Position,
|
||||||
|
SpotifyId = spotifyTrack.SpotifyId,
|
||||||
|
MatchedSong = externalSong
|
||||||
|
};
|
||||||
|
|
||||||
|
matchedTracks.Add(matchedTrack);
|
||||||
|
|
||||||
|
// Convert external song to Jellyfin item format and add to finalItems
|
||||||
|
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);
|
||||||
|
externalUsedCount++;
|
||||||
|
manualExternalCount++;
|
||||||
|
|
||||||
|
_logger.LogInformation("✓ Using manual external mapping for {Title}: {Provider} {ExternalId}",
|
||||||
|
spotifyTrack.Title, provider, externalId);
|
||||||
|
continue; // Skip to next track
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to fetch manually mapped Jellyfin item {Id}", manualJellyfinId);
|
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", spotifyTrack.Title);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for external manual mapping if no Jellyfin mapping found
|
// If no manual external mapping, try fuzzy matching with local Jellyfin tracks
|
||||||
if (!matchedJellyfinItem.HasValue)
|
double bestScore = 0;
|
||||||
{
|
|
||||||
var externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}";
|
|
||||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var doc = JsonDocument.Parse(externalMappingJson);
|
|
||||||
var root = doc.RootElement;
|
|
||||||
|
|
||||||
string? provider = null;
|
|
||||||
string? externalId = null;
|
|
||||||
|
|
||||||
if (root.TryGetProperty("provider", out var providerEl))
|
|
||||||
{
|
|
||||||
provider = providerEl.GetString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (root.TryGetProperty("id", out var idEl))
|
|
||||||
{
|
|
||||||
externalId = idEl.GetString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
|
|
||||||
{
|
|
||||||
// Fetch full metadata from the provider instead of using minimal Spotify data
|
|
||||||
Song? externalSong = null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var metadataScope = _serviceProvider.CreateScope();
|
|
||||||
var metadataServiceForFetch = metadataScope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
|
|
||||||
externalSong = await metadataServiceForFetch.GetSongAsync(provider, externalId);
|
|
||||||
|
|
||||||
if (externalSong != null)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("✓ Fetched full metadata for manual external mapping: {Title} by {Artist}",
|
|
||||||
externalSong.Title, externalSong.Artist);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Failed to fetch metadata for {Provider} ID {ExternalId}, using fallback",
|
|
||||||
provider, externalId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Error fetching metadata for {Provider} ID {ExternalId}, using fallback",
|
|
||||||
provider, externalId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to minimal metadata if fetch failed
|
|
||||||
if (externalSong == null)
|
|
||||||
{
|
|
||||||
externalSong = new Song
|
|
||||||
{
|
|
||||||
Id = $"ext-{provider}-song-{externalId}",
|
|
||||||
Title = spotifyTrack.Title,
|
|
||||||
Artist = spotifyTrack.PrimaryArtist,
|
|
||||||
Album = spotifyTrack.Album,
|
|
||||||
Duration = spotifyTrack.DurationMs / 1000,
|
|
||||||
Isrc = spotifyTrack.Isrc,
|
|
||||||
IsLocal = false,
|
|
||||||
ExternalProvider = provider,
|
|
||||||
ExternalId = externalId
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
var matchedTrack = new MatchedTrack
|
|
||||||
{
|
|
||||||
Position = spotifyTrack.Position,
|
|
||||||
SpotifyId = spotifyTrack.SpotifyId,
|
|
||||||
MatchedSong = externalSong
|
|
||||||
};
|
|
||||||
|
|
||||||
matchedTracks.Add(matchedTrack);
|
|
||||||
|
|
||||||
// Convert external song to Jellyfin item format and add to finalItems
|
|
||||||
var externalItem = responseBuilder.ConvertSongToJellyfinItem(externalSong);
|
|
||||||
finalItems.Add(externalItem);
|
|
||||||
externalUsedCount++;
|
|
||||||
manualExternalCount++;
|
|
||||||
|
|
||||||
_logger.LogInformation("✓ Using manual external mapping for {Title}: {Provider} {ExternalId}",
|
|
||||||
spotifyTrack.Title, provider, externalId);
|
|
||||||
continue; // Skip to next track
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", spotifyTrack.Title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SECOND: If no manual mapping, try fuzzy matching
|
foreach (var kvp in jellyfinItemsByName)
|
||||||
if (!matchedJellyfinItem.HasValue)
|
|
||||||
{
|
{
|
||||||
double bestScore = 0;
|
if (usedJellyfinItems.Contains(kvp.Key)) continue;
|
||||||
|
|
||||||
foreach (var kvp in jellyfinItemsByName)
|
var item = kvp.Value;
|
||||||
|
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||||
|
var artist = "";
|
||||||
|
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||||
{
|
{
|
||||||
if (usedJellyfinItems.Contains(kvp.Key)) continue;
|
artist = artistsEl[0].GetString() ?? "";
|
||||||
|
}
|
||||||
var item = kvp.Value;
|
|
||||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
var titleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, title);
|
||||||
var artist = "";
|
var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist);
|
||||||
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
|
||||||
{
|
|
||||||
artist = artistsEl[0].GetString() ?? "";
|
if (totalScore > bestScore && totalScore >= 70)
|
||||||
}
|
{
|
||||||
|
bestScore = totalScore;
|
||||||
var titleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, title);
|
matchedJellyfinItem = item;
|
||||||
var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist);
|
matchedKey = kvp.Key;
|
||||||
var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
|
|
||||||
|
|
||||||
if (totalScore > bestScore && totalScore >= 70)
|
|
||||||
{
|
|
||||||
bestScore = totalScore;
|
|
||||||
matchedJellyfinItem = item;
|
|
||||||
matchedKey = kvp.Key;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,75 +2299,39 @@
|
|||||||
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 };
|
const externalProvider = document.getElementById('map-external-provider').value;
|
||||||
|
const externalId = document.getElementById('map-external-id').value.trim();
|
||||||
|
|
||||||
if (mappingType === 'jellyfin') {
|
if (!externalId) {
|
||||||
const jellyfinId = document.getElementById('map-selected-jellyfin-id').value;
|
showToast('Please enter an external provider ID', 'error');
|
||||||
if (!jellyfinId) {
|
return;
|
||||||
showToast('Please select a track', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
requestBody.jellyfinId = jellyfinId;
|
|
||||||
} else {
|
|
||||||
const externalProvider = document.getElementById('map-external-provider').value;
|
|
||||||
const externalId = document.getElementById('map-external-id').value.trim();
|
|
||||||
if (!externalId) {
|
|
||||||
showToast('Please enter an external provider ID', 'error');
|
|
||||||
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');
|
||||||
const originalText = saveBtn.textContent;
|
const originalText = saveBtn.textContent;
|
||||||
@@ -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 ${requestBody.externalProvider} mapping...`, 'info', 8000);
|
||||||
showToast('🔄 Rebuilding playlist with your local track mapping...', 'info', 8000);
|
|
||||||
} else {
|
|
||||||
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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user