mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 15:45:10 -05:00
Compare commits
23 Commits
d9c0b8bb54
...
328a6a0eea
| Author | SHA1 | Date | |
|---|---|---|---|
|
328a6a0eea
|
|||
|
9abb53de1a
|
|||
|
349fb740a2
|
|||
|
b604d61039
|
|||
|
3b8d83b43e
|
|||
|
8555b67a38
|
|||
|
629e95ac30
|
|||
|
2153a24c86
|
|||
|
1ddb3954f3
|
|||
|
3319c9b21b
|
|||
|
8966fb1fa2
|
|||
|
3b24ef3e78
|
|||
|
dbeb060d52
|
|||
|
2155a287a5
|
|||
|
cb57b406c1
|
|||
|
e91833ebbb
|
|||
|
2e1577eb5a
|
|||
|
7cb722c396
|
|||
|
9dcaddb2db
|
|||
|
5766cf9f62
|
|||
|
a12d5ea3c9
|
|||
|
25bbf45cbb
|
|||
|
3fd13b855d
|
@@ -5,6 +5,7 @@ using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Spotify;
|
||||
using allstarr.Services.Jellyfin;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services;
|
||||
using allstarr.Filters;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
@@ -37,7 +38,11 @@ public class AdminController : ControllerBase
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly HttpClient _jellyfinHttpClient;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly string _envFilePath;
|
||||
private readonly List<string> _squidWtfApiUrls;
|
||||
private static int _urlIndex = 0;
|
||||
private static readonly object _urlIndexLock = new();
|
||||
private const string CacheDirectory = "/app/cache/spotify";
|
||||
|
||||
public AdminController(
|
||||
@@ -55,6 +60,7 @@ public class AdminController : ControllerBase
|
||||
SpotifyPlaylistFetcher playlistFetcher,
|
||||
RedisCacheService cache,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IServiceProvider serviceProvider,
|
||||
SpotifyTrackMatchingService? matchingService = null)
|
||||
{
|
||||
_logger = logger;
|
||||
@@ -72,6 +78,10 @@ public class AdminController : ControllerBase
|
||||
_matchingService = matchingService;
|
||||
_cache = cache;
|
||||
_jellyfinHttpClient = httpClientFactory.CreateClient();
|
||||
_serviceProvider = serviceProvider;
|
||||
|
||||
// Decode SquidWTF base URLs
|
||||
_squidWtfApiUrls = DecodeSquidWtfUrls();
|
||||
|
||||
// .env file path is always /app/.env in Docker (mounted from host)
|
||||
// In development, it's in the parent directory of ContentRootPath
|
||||
@@ -82,6 +92,38 @@ public class AdminController : ControllerBase
|
||||
_logger.LogInformation("Admin controller initialized. .env path: {EnvFilePath}", _envFilePath);
|
||||
}
|
||||
|
||||
private static List<string> DecodeSquidWtfUrls()
|
||||
{
|
||||
var encodedUrls = new[]
|
||||
{
|
||||
"aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton
|
||||
"aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=", // binimum
|
||||
"aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // kinoplus
|
||||
"aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // spoti-2
|
||||
"aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // spoti-1
|
||||
"aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf
|
||||
"aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund
|
||||
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze
|
||||
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel
|
||||
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==" // maus
|
||||
};
|
||||
|
||||
return encodedUrls
|
||||
.Select(encoded => System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(encoded)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to safely check if a dynamic cache result has a value
|
||||
/// Handles the case where JsonElement cannot be compared to null directly
|
||||
/// </summary>
|
||||
private static bool HasValue(object? obj)
|
||||
{
|
||||
if (obj == null) return false;
|
||||
if (obj is JsonElement jsonEl) return jsonEl.ValueKind != JsonValueKind.Null && jsonEl.ValueKind != JsonValueKind.Undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current system status and configuration
|
||||
/// </summary>
|
||||
@@ -144,6 +186,27 @@ public class AdminController : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a random SquidWTF base URL for searching (round-robin)
|
||||
/// </summary>
|
||||
[HttpGet("squidwtf-base-url")]
|
||||
public IActionResult GetSquidWtfBaseUrl()
|
||||
{
|
||||
if (_squidWtfApiUrls.Count == 0)
|
||||
{
|
||||
return NotFound(new { error = "No SquidWTF base URLs configured" });
|
||||
}
|
||||
|
||||
string baseUrl;
|
||||
lock (_urlIndexLock)
|
||||
{
|
||||
baseUrl = _squidWtfApiUrls[_urlIndex];
|
||||
_urlIndex = (_urlIndex + 1) % _squidWtfApiUrls.Count;
|
||||
}
|
||||
|
||||
return Ok(new { baseUrl });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get list of configured playlists with their current data
|
||||
/// </summary>
|
||||
@@ -423,6 +486,54 @@ public class AdminController : ControllerBase
|
||||
_logger.LogWarning("Playlist {Name} has no JellyfinId configured", config.Name);
|
||||
}
|
||||
|
||||
// Get lyrics completion status
|
||||
try
|
||||
{
|
||||
var tracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
|
||||
if (tracks.Count > 0)
|
||||
{
|
||||
var lyricsWithCount = 0;
|
||||
var lyricsWithoutCount = 0;
|
||||
|
||||
foreach (var track in tracks)
|
||||
{
|
||||
var cacheKey = $"lyrics:{track.PrimaryArtist}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
|
||||
var existingLyrics = await _cache.GetStringAsync(cacheKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(existingLyrics))
|
||||
{
|
||||
lyricsWithCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
lyricsWithoutCount++;
|
||||
}
|
||||
}
|
||||
|
||||
playlistInfo["lyricsTotal"] = tracks.Count;
|
||||
playlistInfo["lyricsCached"] = lyricsWithCount;
|
||||
playlistInfo["lyricsMissing"] = lyricsWithoutCount;
|
||||
playlistInfo["lyricsPercentage"] = tracks.Count > 0
|
||||
? (int)Math.Round((double)lyricsWithCount / tracks.Count * 100)
|
||||
: 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
playlistInfo["lyricsTotal"] = 0;
|
||||
playlistInfo["lyricsCached"] = 0;
|
||||
playlistInfo["lyricsMissing"] = 0;
|
||||
playlistInfo["lyricsPercentage"] = 0;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get lyrics completion for playlist {Name}", config.Name);
|
||||
playlistInfo["lyricsTotal"] = 0;
|
||||
playlistInfo["lyricsCached"] = 0;
|
||||
playlistInfo["lyricsMissing"] = 0;
|
||||
playlistInfo["lyricsPercentage"] = 0;
|
||||
}
|
||||
|
||||
playlists.Add(playlistInfo);
|
||||
}
|
||||
|
||||
@@ -508,11 +619,17 @@ public class AdminController : ControllerBase
|
||||
// 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;
|
||||
string? manualMappingType = null;
|
||||
string? manualMappingId = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
{
|
||||
// Manual Jellyfin mapping exists - this track is definitely local
|
||||
isLocal = true;
|
||||
isManualMapping = true;
|
||||
manualMappingType = "jellyfin";
|
||||
manualMappingId = manualJellyfinId;
|
||||
_logger.LogDebug("✓ Manual Jellyfin mapping found for {Title}: Jellyfin ID {Id}",
|
||||
track.Title, manualJellyfinId);
|
||||
}
|
||||
@@ -520,22 +637,38 @@ public class AdminController : ControllerBase
|
||||
{
|
||||
// Check for external manual mapping
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||
var externalMapping = await _cache.GetAsync<dynamic>(externalMappingKey);
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
if (externalMapping != null)
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
var provider = externalMapping.provider?.ToString();
|
||||
var externalId = externalMapping.id?.ToString();
|
||||
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
||||
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, (object)provider, (object)externalId);
|
||||
track.Title, provider, externalId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -574,7 +707,14 @@ public class AdminController : ControllerBase
|
||||
// If not local, check if it's externally matched or missing
|
||||
if (isLocal != true)
|
||||
{
|
||||
if (matchedSpotifyIds.Contains(track.SpotifyId))
|
||||
// Check if there's a manual external mapping
|
||||
if (isManualMapping && manualMappingType == "external")
|
||||
{
|
||||
// Track has manual external mapping - it's available externally
|
||||
isLocal = false;
|
||||
// externalProvider already set above
|
||||
}
|
||||
else if (matchedSpotifyIds.Contains(track.SpotifyId))
|
||||
{
|
||||
// Track is externally matched (search succeeded)
|
||||
isLocal = false;
|
||||
@@ -600,7 +740,10 @@ public class AdminController : ControllerBase
|
||||
albumArtUrl = track.AlbumArtUrl,
|
||||
isLocal = isLocal,
|
||||
externalProvider = externalProvider,
|
||||
searchQuery = isLocal == false ? $"{track.Title} {track.PrimaryArtist}" : null
|
||||
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null, // Set for both external and missing
|
||||
isManualMapping = isManualMapping,
|
||||
manualMappingType = manualMappingType,
|
||||
manualMappingId = manualMappingId
|
||||
});
|
||||
}
|
||||
|
||||
@@ -644,13 +787,22 @@ public class AdminController : ControllerBase
|
||||
{
|
||||
// Check for external manual mapping
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||
var externalMapping = await _cache.GetAsync<dynamic>(externalMappingKey);
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
if (externalMapping != null)
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
var provider = externalMapping.provider?.ToString();
|
||||
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;
|
||||
@@ -688,7 +840,7 @@ public class AdminController : ControllerBase
|
||||
albumArtUrl = track.AlbumArtUrl,
|
||||
isLocal = isLocal,
|
||||
externalProvider = externalProvider,
|
||||
searchQuery = isLocal == false ? $"{track.Title} {track.PrimaryArtist}" : null
|
||||
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null // Set for both external and missing
|
||||
});
|
||||
}
|
||||
|
||||
@@ -919,22 +1071,29 @@ public class AdminController : ControllerBase
|
||||
{
|
||||
if (hasJellyfinMapping)
|
||||
{
|
||||
// Store Jellyfin mapping in cache
|
||||
// Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
|
||||
await _cache.SetAsync(mappingKey, request.JellyfinId!, TimeSpan.FromDays(365));
|
||||
await _cache.SetAsync(mappingKey, request.JellyfinId!);
|
||||
|
||||
// Also save to file for persistence across restarts
|
||||
await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, request.JellyfinId!, null, null);
|
||||
|
||||
_logger.LogInformation("Manual Jellyfin mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}",
|
||||
decodedName, request.SpotifyId, request.JellyfinId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Store external mapping in cache
|
||||
// Store external mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
|
||||
var externalMapping = new { provider = request.ExternalProvider, id = request.ExternalId };
|
||||
await _cache.SetAsync(externalMappingKey, externalMapping, TimeSpan.FromDays(365));
|
||||
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, request.ExternalProvider, request.ExternalId);
|
||||
decodedName, request.SpotifyId, normalizedProvider, request.ExternalId);
|
||||
}
|
||||
|
||||
// Clear all related caches to force rebuild
|
||||
@@ -946,47 +1105,104 @@ public class AdminController : ControllerBase
|
||||
await _cache.DeleteAsync(orderedCacheKey);
|
||||
await _cache.DeleteAsync(playlistItemsKey);
|
||||
|
||||
_logger.LogInformation("Cleared playlist caches for {Playlist} to force rebuild", decodedName);
|
||||
|
||||
// Fetch the mapped Jellyfin track details to return to the UI
|
||||
string? trackTitle = null;
|
||||
string? trackArtist = null;
|
||||
string? trackAlbum = null;
|
||||
|
||||
// Also delete file caches to force rebuild
|
||||
try
|
||||
{
|
||||
var userId = _jellyfinSettings.UserId;
|
||||
var trackUrl = $"{_jellyfinSettings.Url}/Items/{request.JellyfinId}";
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
var cacheDir = "/app/cache/spotify";
|
||||
var safeName = string.Join("_", decodedName.Split(Path.GetInvalidFileNameChars()));
|
||||
var matchedFile = Path.Combine(cacheDir, $"{safeName}_matched.json");
|
||||
var itemsFile = Path.Combine(cacheDir, $"{safeName}_items.json");
|
||||
|
||||
if (System.IO.File.Exists(matchedFile))
|
||||
{
|
||||
trackUrl += $"?UserId={userId}";
|
||||
System.IO.File.Delete(matchedFile);
|
||||
_logger.LogDebug("Deleted matched tracks file cache for {Playlist}", decodedName);
|
||||
}
|
||||
|
||||
var trackRequest = new HttpRequestMessage(HttpMethod.Get, trackUrl);
|
||||
trackRequest.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(trackRequest);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
if (System.IO.File.Exists(itemsFile))
|
||||
{
|
||||
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);
|
||||
System.IO.File.Delete(itemsFile);
|
||||
_logger.LogDebug("Deleted playlist items file cache for {Playlist}", decodedName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch mapped track details, but mapping was saved");
|
||||
_logger.LogWarning(ex, "Failed to delete file caches for {Playlist}", decodedName);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Cleared playlist caches for {Playlist} to force rebuild", decodedName);
|
||||
|
||||
// Fetch the mapped track details to return to the UI
|
||||
string? trackTitle = null;
|
||||
string? trackArtist = null;
|
||||
string? trackAlbum = null;
|
||||
bool isLocalMapping = hasJellyfinMapping;
|
||||
|
||||
if (hasJellyfinMapping)
|
||||
{
|
||||
// Fetch Jellyfin track details
|
||||
try
|
||||
{
|
||||
var userId = _jellyfinSettings.UserId;
|
||||
var trackUrl = $"{_jellyfinSettings.Url}/Items/{request.JellyfinId}";
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
trackUrl += $"?UserId={userId}";
|
||||
}
|
||||
|
||||
var trackRequest = new HttpRequestMessage(HttpMethod.Get, trackUrl);
|
||||
trackRequest.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(trackRequest);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var trackData = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(trackData);
|
||||
var track = doc.RootElement;
|
||||
|
||||
trackTitle = track.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null;
|
||||
trackArtist = track.TryGetProperty("AlbumArtist", out var artistEl) ? artistEl.GetString() :
|
||||
(track.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0
|
||||
? artistsEl[0].GetString() : null);
|
||||
trackAlbum = track.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to fetch Jellyfin track {Id}: {StatusCode}", request.JellyfinId, response.StatusCode);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch mapped track details, but mapping was saved");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fetch external provider track details
|
||||
try
|
||||
{
|
||||
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
|
||||
@@ -1021,11 +1237,12 @@ public class AdminController : ControllerBase
|
||||
// Return success with track details if available
|
||||
var mappedTrack = new
|
||||
{
|
||||
id = request.JellyfinId,
|
||||
id = hasJellyfinMapping ? request.JellyfinId : request.ExternalId,
|
||||
title = trackTitle ?? "Unknown",
|
||||
artist = trackArtist ?? "Unknown",
|
||||
album = trackAlbum ?? "Unknown",
|
||||
isLocal = true
|
||||
isLocal = isLocalMapping,
|
||||
externalProvider = hasExternalMapping ? request.ExternalProvider!.ToLowerInvariant() : null
|
||||
};
|
||||
|
||||
return Ok(new
|
||||
@@ -2137,6 +2354,29 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets current active sessions for debugging.
|
||||
/// </summary>
|
||||
[HttpGet("sessions")]
|
||||
public IActionResult GetActiveSessions()
|
||||
{
|
||||
try
|
||||
{
|
||||
var sessionManager = HttpContext.RequestServices.GetService<JellyfinSessionManager>();
|
||||
if (sessionManager == null)
|
||||
{
|
||||
return BadRequest(new { error = "Session manager not available" });
|
||||
}
|
||||
|
||||
var sessionInfo = sessionManager.GetSessionsInfo();
|
||||
return Ok(sessionInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to trigger GC after large file operations to prevent memory leaks.
|
||||
/// </summary>
|
||||
@@ -2427,6 +2667,100 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Saves a manual mapping to file for persistence across restarts.
|
||||
/// Manual mappings NEVER expire - they are permanent user decisions.
|
||||
/// </summary>
|
||||
private async Task SaveManualMappingToFileAsync(
|
||||
string playlistName,
|
||||
string spotifyId,
|
||||
string? jellyfinId,
|
||||
string? externalProvider,
|
||||
string? externalId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mappingsDir = "/app/cache/mappings";
|
||||
Directory.CreateDirectory(mappingsDir);
|
||||
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var filePath = Path.Combine(mappingsDir, $"{safeName}_mappings.json");
|
||||
|
||||
// Load existing mappings
|
||||
var mappings = new Dictionary<string, ManualMappingEntry>();
|
||||
if (System.IO.File.Exists(filePath))
|
||||
{
|
||||
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
||||
mappings = JsonSerializer.Deserialize<Dictionary<string, ManualMappingEntry>>(json)
|
||||
?? new Dictionary<string, ManualMappingEntry>();
|
||||
}
|
||||
|
||||
// Add or update mapping
|
||||
mappings[spotifyId] = new ManualMappingEntry
|
||||
{
|
||||
SpotifyId = spotifyId,
|
||||
JellyfinId = jellyfinId,
|
||||
ExternalProvider = externalProvider,
|
||||
ExternalId = externalId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Save back to file
|
||||
var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(filePath, updatedJson);
|
||||
|
||||
_logger.LogDebug("💾 Saved manual mapping to file: {Playlist} - {SpotifyId}", playlistName, spotifyId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save manual mapping to file for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prefetch lyrics for a specific playlist
|
||||
/// </summary>
|
||||
[HttpPost("playlists/{name}/prefetch-lyrics")]
|
||||
public async Task<IActionResult> PrefetchPlaylistLyrics(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
|
||||
try
|
||||
{
|
||||
var lyricsPrefetchService = _serviceProvider.GetService<allstarr.Services.Lyrics.LyricsPrefetchService>();
|
||||
|
||||
if (lyricsPrefetchService == null)
|
||||
{
|
||||
return StatusCode(500, new { error = "Lyrics prefetch service not available" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Starting lyrics prefetch for playlist: {Playlist}", decodedName);
|
||||
|
||||
var (fetched, cached, missing) = await lyricsPrefetchService.PrefetchPlaylistLyricsAsync(
|
||||
decodedName,
|
||||
HttpContext.RequestAborted);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Lyrics prefetch complete",
|
||||
playlist = decodedName,
|
||||
fetched,
|
||||
cached,
|
||||
missing,
|
||||
total = fetched + cached + missing
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to prefetch lyrics for playlist {Playlist}", decodedName);
|
||||
return StatusCode(500, new { error = $"Failed to prefetch lyrics: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
public class ManualMappingRequest
|
||||
@@ -2437,6 +2771,15 @@ public class ManualMappingRequest
|
||||
public string? ExternalId { get; set; }
|
||||
}
|
||||
|
||||
public class ManualMappingEntry
|
||||
{
|
||||
public string SpotifyId { get; set; } = "";
|
||||
public string? JellyfinId { get; set; }
|
||||
public string? ExternalProvider { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class ConfigUpdateRequest
|
||||
{
|
||||
public Dictionary<string, string> Updates { get; set; } = new();
|
||||
|
||||
@@ -2006,6 +2006,7 @@ public class JellyfinController : ControllerBase
|
||||
var doc = JsonDocument.Parse(body);
|
||||
string? itemId = null;
|
||||
string? itemName = null;
|
||||
long? positionTicks = null;
|
||||
|
||||
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
|
||||
{
|
||||
@@ -2016,6 +2017,18 @@ public class JellyfinController : ControllerBase
|
||||
{
|
||||
itemName = itemNameProp.GetString();
|
||||
}
|
||||
|
||||
if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp))
|
||||
{
|
||||
positionTicks = posProp.GetInt64();
|
||||
}
|
||||
|
||||
// Track the playing item for scrobbling on session cleanup
|
||||
var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers);
|
||||
if (!string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId))
|
||||
{
|
||||
_sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(itemId))
|
||||
{
|
||||
@@ -2050,7 +2063,7 @@ public class JellyfinController : ControllerBase
|
||||
var playbackStart = new
|
||||
{
|
||||
ItemId = itemId,
|
||||
PositionTicks = doc.RootElement.TryGetProperty("PositionTicks", out var posProp) ? posProp.GetInt64() : 0,
|
||||
PositionTicks = positionTicks ?? 0,
|
||||
// Let Jellyfin fetch the item details - don't include NowPlayingItem
|
||||
};
|
||||
|
||||
@@ -2064,7 +2077,6 @@ public class JellyfinController : ControllerBase
|
||||
_logger.LogInformation("✓ Playback start forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||
|
||||
// NOW ensure session exists with capabilities (after playback is reported)
|
||||
var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers);
|
||||
if (!string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
var sessionCreated = await _sessionManager.EnsureSessionAsync(deviceId, client ?? "Unknown", device ?? "Unknown", version ?? "1.0", Request.Headers);
|
||||
@@ -2155,6 +2167,12 @@ public class JellyfinController : ControllerBase
|
||||
positionTicks = posProp.GetInt64();
|
||||
}
|
||||
|
||||
// Track the playing item for scrobbling on session cleanup
|
||||
if (!string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId))
|
||||
{
|
||||
_sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(itemId))
|
||||
{
|
||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||
@@ -2217,6 +2235,7 @@ public class JellyfinController : ControllerBase
|
||||
string? itemId = null;
|
||||
string? itemName = null;
|
||||
long? positionTicks = null;
|
||||
string? deviceId = null;
|
||||
|
||||
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
|
||||
{
|
||||
@@ -2233,6 +2252,12 @@ public class JellyfinController : ControllerBase
|
||||
positionTicks = posProp.GetInt64();
|
||||
}
|
||||
|
||||
// Try to get device ID from headers for session management
|
||||
if (Request.Headers.TryGetValue("X-Emby-Device-Id", out var deviceIdHeader))
|
||||
{
|
||||
deviceId = deviceIdHeader.FirstOrDefault();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(itemId))
|
||||
{
|
||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||
@@ -2244,6 +2269,14 @@ public class JellyfinController : ControllerBase
|
||||
: "unknown";
|
||||
_logger.LogInformation("🎵 External track playback stopped: {Name} at {Position} ({Provider}/{ExternalId})",
|
||||
itemName ?? "Unknown", position, provider, externalId);
|
||||
|
||||
// Mark session as potentially ended after playback stops
|
||||
// Wait 50 seconds for next song to start before cleaning up
|
||||
if (!string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
_sessionManager.MarkSessionPotentiallyEnded(deviceId, TimeSpan.FromSeconds(50));
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,10 @@ builder.Services.ConfigureAll<HttpClientFactoryOptions>(options =>
|
||||
MaxAutomaticRedirections = 5
|
||||
};
|
||||
});
|
||||
|
||||
// Suppress verbose HTTP logging - these are logged at Debug level by default
|
||||
// but we want to reduce noise in production logs
|
||||
options.SuppressHandlerScope = true;
|
||||
});
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
@@ -562,6 +566,10 @@ builder.Services.AddHostedService<allstarr.Services.Spotify.SpotifyMissingTracks
|
||||
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyTrackMatchingService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyTrackMatchingService>());
|
||||
|
||||
// Register lyrics prefetch service (prefetches lyrics for all playlist tracks)
|
||||
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsPrefetchService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Lyrics.LyricsPrefetchService>());
|
||||
|
||||
// Register MusicBrainz service for metadata enrichment
|
||||
builder.Services.Configure<allstarr.Models.Settings.MusicBrainzSettings>(options =>
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ using allstarr.Models.Search;
|
||||
using allstarr.Models.Subsonic;
|
||||
using allstarr.Services.Local;
|
||||
using allstarr.Services.Subsonic;
|
||||
using System.Collections.Concurrent;
|
||||
using TagLib;
|
||||
using IOFile = System.IO.File;
|
||||
|
||||
@@ -27,7 +28,7 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
protected readonly string DownloadPath;
|
||||
protected readonly string CachePath;
|
||||
|
||||
protected readonly Dictionary<string, DownloadInfo> ActiveDownloads = new();
|
||||
protected readonly ConcurrentDictionary<string, DownloadInfo> ActiveDownloads = new();
|
||||
protected readonly SemaphoreSlim DownloadLock = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
@@ -298,6 +299,14 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
|
||||
song.LocalPath = localPath;
|
||||
|
||||
// Clean up completed download from tracking after a short delay
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(5)); // Keep for 5 minutes for status checks
|
||||
ActiveDownloads.TryRemove(songId, out _);
|
||||
Logger.LogDebug("Cleaned up completed download tracking for {SongId}", songId);
|
||||
});
|
||||
|
||||
// Register BEFORE releasing lock to prevent race conditions (both cache and download modes)
|
||||
await LocalLibraryService.RegisterDownloadedSongAsync(song, localPath);
|
||||
|
||||
@@ -360,6 +369,14 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
{
|
||||
downloadInfo.Status = DownloadStatus.Failed;
|
||||
downloadInfo.ErrorMessage = ex.Message;
|
||||
|
||||
// Clean up failed download from tracking after a short delay
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(2)); // Keep for 2 minutes for error reporting
|
||||
ActiveDownloads.TryRemove(songId, out _);
|
||||
Logger.LogDebug("Cleaned up failed download tracking for {SongId}", songId);
|
||||
});
|
||||
}
|
||||
Logger.LogError(ex, "Download failed for {SongId}", songId);
|
||||
throw;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using allstarr.Models.Domain;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
@@ -10,14 +11,19 @@ public class CacheWarmingService : IHostedService
|
||||
{
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly ILogger<CacheWarmingService> _logger;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private const string GenreCacheDirectory = "/app/cache/genres";
|
||||
private const string PlaylistCacheDirectory = "/app/cache/spotify";
|
||||
private const string MappingsCacheDirectory = "/app/cache/mappings";
|
||||
private const string LyricsCacheDirectory = "/app/cache/lyrics";
|
||||
|
||||
public CacheWarmingService(
|
||||
RedisCacheService cache,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<CacheWarmingService> logger)
|
||||
{
|
||||
_cache = cache;
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -28,6 +34,8 @@ public class CacheWarmingService : IHostedService
|
||||
var startTime = DateTime.UtcNow;
|
||||
var genresWarmed = 0;
|
||||
var playlistsWarmed = 0;
|
||||
var mappingsWarmed = 0;
|
||||
var lyricsWarmed = 0;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -36,11 +44,17 @@ public class CacheWarmingService : IHostedService
|
||||
|
||||
// Warm playlist cache
|
||||
playlistsWarmed = await WarmPlaylistCacheAsync(cancellationToken);
|
||||
|
||||
// Warm manual mappings cache
|
||||
mappingsWarmed = await WarmManualMappingsCacheAsync(cancellationToken);
|
||||
|
||||
// Warm lyrics cache
|
||||
lyricsWarmed = await WarmLyricsCacheAsync(cancellationToken);
|
||||
|
||||
var duration = DateTime.UtcNow - startTime;
|
||||
_logger.LogInformation(
|
||||
"✅ Cache warming complete in {Duration:F1}s: {Genres} genres, {Playlists} playlists",
|
||||
duration.TotalSeconds, genresWarmed, playlistsWarmed);
|
||||
"✅ Cache warming complete in {Duration:F1}s: {Genres} genres, {Playlists} playlists, {Mappings} manual mappings, {Lyrics} lyrics",
|
||||
duration.TotalSeconds, genresWarmed, playlistsWarmed, mappingsWarmed, lyricsWarmed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -115,10 +129,12 @@ public class CacheWarmingService : IHostedService
|
||||
return 0;
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(PlaylistCacheDirectory, "*_items.json");
|
||||
var itemsFiles = Directory.GetFiles(PlaylistCacheDirectory, "*_items.json");
|
||||
var matchedFiles = Directory.GetFiles(PlaylistCacheDirectory, "*_matched.json");
|
||||
var warmedCount = 0;
|
||||
|
||||
foreach (var file in files)
|
||||
// Warm playlist items cache
|
||||
foreach (var file in itemsFiles)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
break;
|
||||
@@ -145,13 +161,51 @@ public class CacheWarmingService : IHostedService
|
||||
await _cache.SetAsync(redisKey, items, TimeSpan.FromHours(24));
|
||||
warmedCount++;
|
||||
|
||||
_logger.LogDebug("🔥 Warmed playlist cache for {Playlist} ({Count} items)",
|
||||
_logger.LogDebug("🔥 Warmed playlist items cache for {Playlist} ({Count} items)",
|
||||
playlistName, items.Count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to warm playlist cache from file: {File}", file);
|
||||
_logger.LogWarning(ex, "Failed to warm playlist items cache from file: {File}", file);
|
||||
}
|
||||
}
|
||||
|
||||
// Warm matched tracks cache
|
||||
foreach (var file in matchedFiles)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
try
|
||||
{
|
||||
// Check if cache is expired (1 hour)
|
||||
var fileInfo = new FileInfo(file);
|
||||
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc > TimeSpan.FromHours(1))
|
||||
{
|
||||
continue; // Skip expired matched tracks
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(file, cancellationToken);
|
||||
var matchedTracks = JsonSerializer.Deserialize<List<MatchedTrack>>(json);
|
||||
|
||||
if (matchedTracks != null && matchedTracks.Count > 0)
|
||||
{
|
||||
// Extract playlist name from filename
|
||||
var fileName = Path.GetFileNameWithoutExtension(file);
|
||||
var playlistName = fileName.Replace("_matched", "");
|
||||
|
||||
var redisKey = $"spotify:matched:ordered:{playlistName}";
|
||||
await _cache.SetAsync(redisKey, matchedTracks, TimeSpan.FromHours(1));
|
||||
warmedCount++;
|
||||
|
||||
_logger.LogDebug("🔥 Warmed matched tracks cache for {Playlist} ({Count} tracks)",
|
||||
playlistName, matchedTracks.Count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to warm matched tracks cache from file: {File}", file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +216,104 @@ public class CacheWarmingService : IHostedService
|
||||
|
||||
return warmedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warms manual mappings cache from file system.
|
||||
/// Manual mappings NEVER expire - they are permanent user decisions.
|
||||
/// </summary>
|
||||
private async Task<int> WarmManualMappingsCacheAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Directory.Exists(MappingsCacheDirectory))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(MappingsCacheDirectory, "*_mappings.json");
|
||||
var warmedCount = 0;
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(file, cancellationToken);
|
||||
var mappings = JsonSerializer.Deserialize<Dictionary<string, ManualMappingEntry>>(json);
|
||||
|
||||
if (mappings != null && mappings.Count > 0)
|
||||
{
|
||||
// Extract playlist name from filename
|
||||
var fileName = Path.GetFileNameWithoutExtension(file);
|
||||
var playlistName = fileName.Replace("_mappings", "");
|
||||
|
||||
foreach (var mapping in mappings.Values)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(mapping.JellyfinId))
|
||||
{
|
||||
// Jellyfin mapping
|
||||
var redisKey = $"spotify:manual-map:{playlistName}:{mapping.SpotifyId}";
|
||||
await _cache.SetAsync(redisKey, mapping.JellyfinId);
|
||||
warmedCount++;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(mapping.ExternalProvider) && !string.IsNullOrEmpty(mapping.ExternalId))
|
||||
{
|
||||
// External mapping
|
||||
var redisKey = $"spotify:external-map:{playlistName}:{mapping.SpotifyId}";
|
||||
var externalMapping = new { provider = mapping.ExternalProvider, id = mapping.ExternalId };
|
||||
await _cache.SetAsync(redisKey, externalMapping);
|
||||
warmedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("🔥 Warmed {Count} manual mappings for {Playlist}",
|
||||
mappings.Count, playlistName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to warm manual mappings from file: {File}", file);
|
||||
}
|
||||
}
|
||||
|
||||
if (warmedCount > 0)
|
||||
{
|
||||
_logger.LogInformation("🔥 Warmed {Count} manual mappings from file system", warmedCount);
|
||||
}
|
||||
|
||||
return warmedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warms lyrics cache from file system using the LyricsPrefetchService.
|
||||
/// </summary>
|
||||
private async Task<int> WarmLyricsCacheAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get the LyricsPrefetchService from DI
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var lyricsPrefetchService = scope.ServiceProvider.GetService<allstarr.Services.Lyrics.LyricsPrefetchService>();
|
||||
|
||||
if (lyricsPrefetchService != null)
|
||||
{
|
||||
await lyricsPrefetchService.WarmCacheFromFilesAsync();
|
||||
|
||||
// Count files to return warmed count
|
||||
if (Directory.Exists(LyricsCacheDirectory))
|
||||
{
|
||||
return Directory.GetFiles(LyricsCacheDirectory, "*.json").Length;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to warm lyrics cache");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private class GenreCacheEntry
|
||||
{
|
||||
@@ -169,4 +321,24 @@ public class CacheWarmingService : IHostedService
|
||||
public string Genre { get; set; } = "";
|
||||
public DateTime CachedAt { get; set; }
|
||||
}
|
||||
|
||||
private class MatchedTrack
|
||||
{
|
||||
public int Position { get; set; }
|
||||
public string SpotifyId { get; set; } = "";
|
||||
public string SpotifyTitle { get; set; } = "";
|
||||
public string SpotifyArtist { get; set; } = "";
|
||||
public string? Isrc { get; set; }
|
||||
public string MatchType { get; set; } = "";
|
||||
public Song? MatchedSong { get; set; }
|
||||
}
|
||||
|
||||
private class ManualMappingEntry
|
||||
{
|
||||
public string SpotifyId { get; set; } = "";
|
||||
public string? JellyfinId { get; set; }
|
||||
public string? ExternalProvider { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,13 +57,14 @@ public class RedisCacheService
|
||||
try
|
||||
{
|
||||
var value = await _db!.StringGetAsync(key);
|
||||
|
||||
if (value.HasValue)
|
||||
{
|
||||
_logger.LogInformation("Redis cache HIT: {Key}", key);
|
||||
_logger.LogDebug("Redis cache HIT: {Key}", key);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Redis cache MISS: {Key}", key);
|
||||
_logger.LogDebug("Redis cache MISS: {Key}", key);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -105,7 +106,7 @@ public class RedisCacheService
|
||||
var result = await _db!.StringSetAsync(key, value, expiry);
|
||||
if (result)
|
||||
{
|
||||
_logger.LogInformation("Redis cache SET: {Key} (TTL: {Expiry})", key, expiry?.ToString() ?? "none");
|
||||
_logger.LogDebug("Redis cache SET: {Key} (TTL: {Expiry})", key, expiry?.ToString() ?? "none");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -142,6 +142,79 @@ public class JellyfinSessionManager : IDisposable
|
||||
_logger.LogDebug("⚠️ SESSION: Cannot update activity - device {DeviceId} not found", deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the currently playing item for a session (for scrobbling on cleanup).
|
||||
/// </summary>
|
||||
public void UpdatePlayingItem(string deviceId, string? itemId, long? positionTicks)
|
||||
{
|
||||
if (_sessions.TryGetValue(deviceId, out var session))
|
||||
{
|
||||
session.LastPlayingItemId = itemId;
|
||||
session.LastPlayingPositionTicks = positionTicks;
|
||||
session.LastActivity = DateTime.UtcNow;
|
||||
_logger.LogDebug("🎵 SESSION: Updated playing item for {DeviceId}: {ItemId} at {Position}",
|
||||
deviceId, itemId, positionTicks);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a session as potentially ended (e.g., after playback stops).
|
||||
/// The session will be cleaned up if no new activity occurs within the timeout.
|
||||
/// </summary>
|
||||
public void MarkSessionPotentiallyEnded(string deviceId, TimeSpan timeout)
|
||||
{
|
||||
if (_sessions.TryGetValue(deviceId, out var session))
|
||||
{
|
||||
_logger.LogDebug("⏰ SESSION: Marking session {DeviceId} as potentially ended, will cleanup in {Seconds}s if no activity",
|
||||
deviceId, timeout.TotalSeconds);
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var markedTime = DateTime.UtcNow;
|
||||
await Task.Delay(timeout);
|
||||
|
||||
// Check if there's been activity since we marked it
|
||||
if (_sessions.TryGetValue(deviceId, out var currentSession) &&
|
||||
currentSession.LastActivity <= markedTime)
|
||||
{
|
||||
_logger.LogInformation("🧹 SESSION: Auto-removing inactive session {DeviceId} after playback stop", deviceId);
|
||||
await RemoveSessionAsync(deviceId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("✓ SESSION: Session {DeviceId} had activity, keeping alive", deviceId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets information about current active sessions for debugging.
|
||||
/// </summary>
|
||||
public object GetSessionsInfo()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var sessions = _sessions.Values.Select(s => new
|
||||
{
|
||||
DeviceId = s.DeviceId,
|
||||
Client = s.Client,
|
||||
Device = s.Device,
|
||||
Version = s.Version,
|
||||
LastActivity = s.LastActivity,
|
||||
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
|
||||
HasWebSocket = s.WebSocket != null,
|
||||
WebSocketState = s.WebSocket?.State.ToString() ?? "None"
|
||||
}).ToList();
|
||||
|
||||
return new
|
||||
{
|
||||
TotalSessions = sessions.Count,
|
||||
ActiveSessions = sessions.Count(s => s.InactiveMinutes < 2),
|
||||
StaleSessions = sessions.Count(s => s.InactiveMinutes >= 2),
|
||||
Sessions = sessions.OrderBy(s => s.InactiveMinutes)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a session when the client disconnects.
|
||||
@@ -172,13 +245,26 @@ public class JellyfinSessionManager : IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
// Optionally notify Jellyfin that the session is ending
|
||||
// (Jellyfin will auto-cleanup inactive sessions anyway)
|
||||
// Report playback stopped to Jellyfin if we have a playing item (for scrobbling)
|
||||
if (!string.IsNullOrEmpty(session.LastPlayingItemId))
|
||||
{
|
||||
var stopPayload = new
|
||||
{
|
||||
ItemId = session.LastPlayingItemId,
|
||||
PositionTicks = session.LastPlayingPositionTicks ?? 0
|
||||
};
|
||||
var stopJson = JsonSerializer.Serialize(stopPayload);
|
||||
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers);
|
||||
_logger.LogInformation("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
|
||||
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
|
||||
}
|
||||
|
||||
// Notify Jellyfin that the session is ending
|
||||
await _proxyService.PostJsonAsync("Sessions/Logout", "{}", session.Headers);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("⚠️ SESSION: Error removing session for {DeviceId}: {Message}", deviceId, ex.Message);
|
||||
_logger.LogWarning("⚠️ SESSION: Error removing session for {DeviceId}: {Message}", deviceId, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -408,12 +494,14 @@ public class JellyfinSessionManager : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stale sessions (inactive for > 10 minutes)
|
||||
var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(10)).ToList();
|
||||
// Clean up stale sessions after 3 minutes of inactivity
|
||||
// This balances cleaning up finished sessions with allowing brief pauses/network issues
|
||||
var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(3)).ToList();
|
||||
foreach (var stale in staleSessions)
|
||||
{
|
||||
_logger.LogInformation("🧹 SESSION: Removing stale session for {DeviceId}", stale.Key);
|
||||
_sessions.TryRemove(stale.Key, out _);
|
||||
_logger.LogInformation("🧹 SESSION: Removing stale session for {DeviceId} (inactive for {Minutes:F1} minutes)",
|
||||
stale.Key, (now - stale.Value.LastActivity).TotalMinutes);
|
||||
await RemoveSessionAsync(stale.Key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,6 +524,8 @@ public class JellyfinSessionManager : IDisposable
|
||||
public DateTime LastActivity { get; set; }
|
||||
public required IHeaderDictionary Headers { get; init; }
|
||||
public ClientWebSocket? WebSocket { get; set; }
|
||||
public string? LastPlayingItemId { get; set; }
|
||||
public long? LastPlayingPositionTicks { get; set; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
258
allstarr/Services/Lyrics/LyricsPrefetchService.cs
Normal file
258
allstarr/Services/Lyrics/LyricsPrefetchService.cs
Normal file
@@ -0,0 +1,258 @@
|
||||
using System.Text.Json;
|
||||
using allstarr.Models.Lyrics;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Spotify;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace allstarr.Services.Lyrics;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that prefetches lyrics for all tracks in injected Spotify playlists.
|
||||
/// Lyrics are cached in Redis and persisted to disk for fast loading on startup.
|
||||
/// </summary>
|
||||
public class LyricsPrefetchService : BackgroundService
|
||||
{
|
||||
private readonly SpotifyImportSettings _spotifySettings;
|
||||
private readonly LrclibService _lrclibService;
|
||||
private readonly SpotifyPlaylistFetcher _playlistFetcher;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly ILogger<LyricsPrefetchService> _logger;
|
||||
private readonly string _lyricsCacheDir = "/app/cache/lyrics";
|
||||
private const int DelayBetweenRequestsMs = 500; // 500ms = 2 requests/second to be respectful
|
||||
|
||||
public LyricsPrefetchService(
|
||||
IOptions<SpotifyImportSettings> spotifySettings,
|
||||
LrclibService lrclibService,
|
||||
SpotifyPlaylistFetcher playlistFetcher,
|
||||
RedisCacheService cache,
|
||||
ILogger<LyricsPrefetchService> logger)
|
||||
{
|
||||
_spotifySettings = spotifySettings.Value;
|
||||
_lrclibService = lrclibService;
|
||||
_playlistFetcher = playlistFetcher;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("LyricsPrefetchService: Starting up...");
|
||||
|
||||
if (!_spotifySettings.Enabled)
|
||||
{
|
||||
_logger.LogInformation("Spotify playlist injection is DISABLED, lyrics prefetch will not run");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure cache directory exists
|
||||
Directory.CreateDirectory(_lyricsCacheDir);
|
||||
|
||||
// Wait for playlist fetcher to initialize
|
||||
await Task.Delay(TimeSpan.FromMinutes(3), stoppingToken);
|
||||
|
||||
// Run initial prefetch
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Running initial lyrics prefetch on startup");
|
||||
await PrefetchAllPlaylistLyricsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during startup lyrics prefetch");
|
||||
}
|
||||
|
||||
// Run periodic prefetch (daily)
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromHours(24), stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
await PrefetchAllPlaylistLyricsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in lyrics prefetch service");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PrefetchAllPlaylistLyricsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("🎵 Starting lyrics prefetch for {Count} playlists", _spotifySettings.Playlists.Count);
|
||||
|
||||
var totalFetched = 0;
|
||||
var totalCached = 0;
|
||||
var totalMissing = 0;
|
||||
|
||||
foreach (var playlist in _spotifySettings.Playlists)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
var (fetched, cached, missing) = await PrefetchPlaylistLyricsAsync(playlist.Name, cancellationToken);
|
||||
totalFetched += fetched;
|
||||
totalCached += cached;
|
||||
totalMissing += missing;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error prefetching lyrics for playlist {Playlist}", playlist.Name);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("✅ Lyrics prefetch complete: {Fetched} fetched, {Cached} already cached, {Missing} not found",
|
||||
totalFetched, totalCached, totalMissing);
|
||||
}
|
||||
|
||||
public async Task<(int Fetched, int Cached, int Missing)> PrefetchPlaylistLyricsAsync(
|
||||
string playlistName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Prefetching lyrics for playlist: {Playlist}", playlistName);
|
||||
|
||||
var tracks = await _playlistFetcher.GetPlaylistTracksAsync(playlistName);
|
||||
if (tracks.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No tracks found for playlist {Playlist}", playlistName);
|
||||
return (0, 0, 0);
|
||||
}
|
||||
|
||||
var fetched = 0;
|
||||
var cached = 0;
|
||||
var missing = 0;
|
||||
|
||||
foreach (var track in tracks)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
// Check if lyrics are already cached
|
||||
var cacheKey = $"lyrics:{track.PrimaryArtist}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
|
||||
var existingLyrics = await _cache.GetStringAsync(cacheKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(existingLyrics))
|
||||
{
|
||||
cached++;
|
||||
_logger.LogDebug("✓ Lyrics already cached for {Artist} - {Track}", track.PrimaryArtist, track.Title);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch lyrics
|
||||
var lyrics = await _lrclibService.GetLyricsAsync(
|
||||
track.Title,
|
||||
track.Artists.ToArray(),
|
||||
track.Album,
|
||||
track.DurationMs / 1000);
|
||||
|
||||
if (lyrics != null)
|
||||
{
|
||||
fetched++;
|
||||
_logger.LogInformation("✓ Fetched lyrics for {Artist} - {Track} (synced: {HasSynced})",
|
||||
track.PrimaryArtist, track.Title, !string.IsNullOrEmpty(lyrics.SyncedLyrics));
|
||||
|
||||
// Save to file cache
|
||||
await SaveLyricsToFileAsync(track.PrimaryArtist, track.Title, track.Album, track.DurationMs / 1000, lyrics);
|
||||
}
|
||||
else
|
||||
{
|
||||
missing++;
|
||||
_logger.LogDebug("✗ No lyrics found for {Artist} - {Track}", track.PrimaryArtist, track.Title);
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
await Task.Delay(DelayBetweenRequestsMs, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to prefetch lyrics for {Artist} - {Track}", track.PrimaryArtist, track.Title);
|
||||
missing++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Playlist {Playlist}: {Fetched} fetched, {Cached} cached, {Missing} missing",
|
||||
playlistName, fetched, cached, missing);
|
||||
|
||||
return (fetched, cached, missing);
|
||||
}
|
||||
|
||||
private async Task SaveLyricsToFileAsync(string artist, string title, string album, int duration, LyricsInfo lyrics)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileName = $"{SanitizeFileName(artist)}_{SanitizeFileName(title)}_{duration}.json";
|
||||
var filePath = Path.Combine(_lyricsCacheDir, fileName);
|
||||
|
||||
var json = JsonSerializer.Serialize(lyrics, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(filePath, json);
|
||||
|
||||
_logger.LogDebug("💾 Saved lyrics to file: {FileName}", fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to save lyrics to file for {Artist} - {Track}", artist, title);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads lyrics from file cache into Redis on startup
|
||||
/// </summary>
|
||||
public async Task WarmCacheFromFilesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(_lyricsCacheDir))
|
||||
{
|
||||
_logger.LogInformation("Lyrics cache directory does not exist, skipping cache warming");
|
||||
return;
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(_lyricsCacheDir, "*.json");
|
||||
if (files.Length == 0)
|
||||
{
|
||||
_logger.LogInformation("No lyrics cache files found");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("🔥 Warming lyrics cache from {Count} files...", files.Length);
|
||||
|
||||
var loaded = 0;
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(file);
|
||||
var lyrics = JsonSerializer.Deserialize<LyricsInfo>(json);
|
||||
|
||||
if (lyrics != null)
|
||||
{
|
||||
var cacheKey = $"lyrics:{lyrics.ArtistName}:{lyrics.TrackName}:{lyrics.AlbumName}:{lyrics.Duration}";
|
||||
await _cache.SetStringAsync(cacheKey, json, TimeSpan.FromDays(30));
|
||||
loaded++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load lyrics from file {File}", Path.GetFileName(file));
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("✅ Warmed {Count} lyrics from file cache", loaded);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error warming lyrics cache from files");
|
||||
}
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string fileName)
|
||||
{
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
return string.Join("_", fileName.Split(invalid, StringSplitOptions.RemoveEmptyEntries))
|
||||
.Replace(" ", "_")
|
||||
.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ public class MusicBrainzService
|
||||
|
||||
// Return the first recording (ISRCs should be unique)
|
||||
var recording = result.Recordings[0];
|
||||
var genres = recording.Genres?.Select(g => g.Name).Where(n => !string.IsNullOrEmpty(n)).ToList() ?? new List<string>();
|
||||
var genres = recording.Genres?.Select(g => g.Name).Where(n => !string.IsNullOrEmpty(n)).ToList() ?? new List<string?>();
|
||||
_logger.LogInformation("✓ Found MusicBrainz recording for ISRC {Isrc}: {Title} by {Artist} (Genres: {Genres})",
|
||||
isrc, recording.Title, recording.ArtistCredit?[0]?.Name ?? "Unknown", string.Join(", ", genres));
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
|
||||
private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count)
|
||||
private DateTime _lastMatchingRun = DateTime.MinValue;
|
||||
private readonly TimeSpan _minimumMatchingInterval = TimeSpan.FromMinutes(5); // Don't run more than once per 5 minutes
|
||||
|
||||
public SpotifyTrackMatchingService(
|
||||
IOptions<SpotifyImportSettings> spotifySettings,
|
||||
@@ -41,6 +43,17 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to safely check if a dynamic cache result has a value
|
||||
/// Handles the case where JsonElement cannot be compared to null directly
|
||||
/// </summary>
|
||||
private static bool HasValue(object? obj)
|
||||
{
|
||||
if (obj == null) return false;
|
||||
if (obj is JsonElement jsonEl) return jsonEl.ValueKind != JsonValueKind.Null && jsonEl.ValueKind != JsonValueKind.Undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
@@ -153,7 +166,17 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Check if we've run too recently (cooldown period)
|
||||
var timeSinceLastRun = DateTime.UtcNow - _lastMatchingRun;
|
||||
if (timeSinceLastRun < _minimumMatchingInterval)
|
||||
{
|
||||
_logger.LogInformation("Skipping track matching - last run was {Seconds}s ago (minimum interval: {MinSeconds}s)",
|
||||
(int)timeSinceLastRun.TotalSeconds, (int)_minimumMatchingInterval.TotalSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== STARTING TRACK MATCHING ===");
|
||||
_lastMatchingRun = DateTime.UtcNow;
|
||||
|
||||
var playlists = _spotifySettings.Playlists;
|
||||
if (playlists.Count == 0)
|
||||
@@ -303,39 +326,40 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
// Check cache - use snapshot/timestamp to detect changes
|
||||
var existingMatched = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||
|
||||
// Check if we have manual mappings that need to be preserved
|
||||
var hasManualMappings = false;
|
||||
foreach (var track in tracksToMatch)
|
||||
// CRITICAL: Skip matching if cache exists and is valid
|
||||
// Only re-match if cache is missing OR if we detect manual mappings that need to be applied
|
||||
if (existingMatched != null && existingMatched.Count > 0)
|
||||
{
|
||||
var manualMappingKey = $"spotify:manual-map:{playlistName}:{track.SpotifyId}";
|
||||
var manualMapping = await _cache.GetAsync<string>(manualMappingKey);
|
||||
if (!string.IsNullOrEmpty(manualMapping))
|
||||
// Check if we have NEW manual mappings that aren't in the cache
|
||||
var hasNewManualMappings = false;
|
||||
foreach (var track in tracksToMatch)
|
||||
{
|
||||
hasManualMappings = true;
|
||||
break;
|
||||
// Check if this track has a manual mapping but isn't in the cached results
|
||||
var manualMappingKey = $"spotify:manual-map:{playlistName}:{track.SpotifyId}";
|
||||
var manualMapping = await _cache.GetAsync<string>(manualMappingKey);
|
||||
|
||||
var externalMappingKey = $"spotify:external-map:{playlistName}:{track.SpotifyId}";
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
var hasManualMapping = !string.IsNullOrEmpty(manualMapping) || !string.IsNullOrEmpty(externalMappingJson);
|
||||
var isInCache = existingMatched.Any(m => m.SpotifyId == track.SpotifyId);
|
||||
|
||||
// If track has manual mapping but isn't in cache, we need to rebuild
|
||||
if (hasManualMapping && !isInCache)
|
||||
{
|
||||
hasNewManualMappings = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for external manual mappings
|
||||
var externalMappingKey = $"spotify:external-map:{playlistName}:{track.SpotifyId}";
|
||||
var externalMapping = await _cache.GetAsync<dynamic>(externalMappingKey);
|
||||
if (externalMapping != null)
|
||||
if (!hasNewManualMappings)
|
||||
{
|
||||
hasManualMappings = true;
|
||||
break;
|
||||
_logger.LogInformation("✓ Playlist {Playlist} already has {Count} matched tracks cached (skipping {ToMatch} new tracks), no re-matching needed",
|
||||
playlistName, existingMatched.Count, tracksToMatch.Count);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if cache exists AND no manual mappings need to be applied
|
||||
if (existingMatched != null && existingMatched.Count >= tracksToMatch.Count && !hasManualMappings)
|
||||
{
|
||||
_logger.LogInformation("Playlist {Playlist} already has {Count} matched tracks cached, skipping",
|
||||
playlistName, existingMatched.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasManualMappings)
|
||||
{
|
||||
_logger.LogInformation("Manual mappings detected for {Playlist}, rebuilding cache to apply them", playlistName);
|
||||
|
||||
_logger.LogInformation("New manual mappings detected for {Playlist}, rebuilding cache to apply them", playlistName);
|
||||
}
|
||||
|
||||
var matchedTracks = new List<MatchedTrack>();
|
||||
@@ -450,13 +474,16 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
// Cache matched tracks with position data
|
||||
await _cache.SetAsync(matchedTracksKey, matchedTracks, TimeSpan.FromHours(1));
|
||||
|
||||
// Save matched tracks to file for persistence across restarts
|
||||
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
|
||||
|
||||
// Also update legacy cache for backward compatibility
|
||||
var legacyKey = $"spotify:matched:{playlistName}";
|
||||
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
|
||||
await _cache.SetAsync(legacyKey, legacySongs, TimeSpan.FromHours(1));
|
||||
|
||||
_logger.LogInformation(
|
||||
"✓ Cached {Matched}/{Total} tracks for {Playlist} (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch})",
|
||||
"✓ Cached {Matched}/{Total} tracks for {Playlist} via search (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - manual mappings will be applied next",
|
||||
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch);
|
||||
|
||||
// Pre-build playlist items cache for instant serving
|
||||
@@ -778,6 +805,8 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
var usedJellyfinItems = new HashSet<string>();
|
||||
var localUsedCount = 0;
|
||||
var externalUsedCount = 0;
|
||||
var manualLocalCount = 0;
|
||||
var manualExternalCount = 0;
|
||||
|
||||
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
|
||||
{
|
||||
@@ -801,6 +830,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
if (itemStatusCode == 200 && itemResponse != null)
|
||||
{
|
||||
matchedJellyfinItem = itemResponse.RootElement;
|
||||
manualLocalCount++;
|
||||
_logger.LogDebug("✓ Using manual Jellyfin mapping for {Title}: Jellyfin ID {Id}",
|
||||
spotifyTrack.Title, manualJellyfinId);
|
||||
}
|
||||
@@ -820,39 +850,90 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
if (!matchedJellyfinItem.HasValue)
|
||||
{
|
||||
var externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}";
|
||||
var externalMapping = await _cache.GetAsync<dynamic>(externalMappingKey);
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
if (externalMapping != null)
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
var provider = externalMapping.provider?.ToString();
|
||||
var externalId = externalMapping.id?.ToString();
|
||||
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))
|
||||
{
|
||||
// Create a matched track entry for the external mapping
|
||||
var externalSong = new Song
|
||||
{
|
||||
Title = spotifyTrack.Title,
|
||||
Artist = spotifyTrack.PrimaryArtist,
|
||||
Album = spotifyTrack.Album,
|
||||
Duration = spotifyTrack.DurationMs / 1000,
|
||||
Isrc = spotifyTrack.Isrc,
|
||||
IsLocal = false,
|
||||
ExternalProvider = provider,
|
||||
ExternalId = externalId
|
||||
};
|
||||
// Fetch full metadata from the provider instead of using minimal Spotify data
|
||||
Song? externalSong = null;
|
||||
|
||||
matchedTracks.Add(new MatchedTrack
|
||||
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, (object)provider, (object)externalId);
|
||||
spotifyTrack.Title, provider, externalId);
|
||||
continue; // Skip to next track
|
||||
}
|
||||
}
|
||||
@@ -930,9 +1011,15 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
// Save to file cache for persistence
|
||||
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
|
||||
|
||||
var manualMappingInfo = "";
|
||||
if (manualLocalCount > 0 || manualExternalCount > 0)
|
||||
{
|
||||
manualMappingInfo = $" [Manual: {manualLocalCount} local, {manualExternalCount} external]";
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL)",
|
||||
playlistName, finalItems.Count, localUsedCount, externalUsedCount);
|
||||
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo}",
|
||||
playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -968,5 +1055,29 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
_logger.LogError(ex, "Failed to save playlist items to file for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves matched tracks to file cache for persistence across restarts.
|
||||
/// </summary>
|
||||
private async Task SaveMatchedTracksToFileAsync(string playlistName, List<MatchedTrack> matchedTracks)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cacheDir = "/app/cache/spotify";
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var filePath = Path.Combine(cacheDir, $"{safeName}_matched.json");
|
||||
|
||||
var json = JsonSerializer.Serialize(matchedTracks, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(filePath, json);
|
||||
|
||||
_logger.LogDebug("💾 Saved {Count} matched tracks to file cache: {Path}", matchedTracks.Count, filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save matched tracks to file for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"System.Net.Http.HttpClient.Default.LogicalHandler": "Warning",
|
||||
"System.Net.Http.HttpClient.Default.ClientHandler": "Warning"
|
||||
}
|
||||
},
|
||||
"SpotifyImport": {
|
||||
"Enabled": false,
|
||||
"SyncStartHour": 16,
|
||||
"SyncStartMinute": 15,
|
||||
"SyncWindowHours": 2,
|
||||
"Playlists": []
|
||||
}
|
||||
"SpotifyImport": {
|
||||
"Enabled": false,
|
||||
"SyncStartHour": 16,
|
||||
"SyncStartMinute": 15,
|
||||
"SyncWindowHours": 2,
|
||||
"Playlists": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"System.Net.Http.HttpClient.Default.LogicalHandler": "Warning",
|
||||
"System.Net.Http.HttpClient.Default.ClientHandler": "Warning"
|
||||
}
|
||||
},
|
||||
"Backend": {
|
||||
"Type": "Subsonic"
|
||||
},
|
||||
|
||||
@@ -387,9 +387,9 @@
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
max-width: 75%;
|
||||
width: 75%;
|
||||
max-height: 65vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -662,13 +662,14 @@
|
||||
<th>Spotify ID</th>
|
||||
<th>Tracks</th>
|
||||
<th>Completion</th>
|
||||
<th>Lyrics</th>
|
||||
<th>Cache Age</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="playlist-table-body">
|
||||
<tr>
|
||||
<td colspan="6" class="loading">
|
||||
<td colspan="7" class="loading">
|
||||
<span class="spinner"></span> Loading playlists...
|
||||
</td>
|
||||
</tr>
|
||||
@@ -879,7 +880,7 @@
|
||||
|
||||
<!-- Track List Modal -->
|
||||
<div class="modal" id="tracks-modal">
|
||||
<div class="modal-content" style="max-width: 700px;">
|
||||
<div class="modal-content" style="max-width: 90%; width: 90%;">
|
||||
<h3 id="tracks-modal-title">Playlist Tracks</h3>
|
||||
<div class="tracks-list" id="tracks-list">
|
||||
<div class="loading">
|
||||
@@ -1271,9 +1272,20 @@
|
||||
<span style="font-size:0.85rem;color:${completionColor};font-weight:500;min-width:40px;">${completionPct}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
${p.lyricsTotal > 0 ? `
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<div style="flex:1;background:var(--bg-tertiary);height:12px;border-radius:6px;overflow:hidden;">
|
||||
<div style="width:${p.lyricsPercentage}%;height:100%;background:${p.lyricsPercentage === 100 ? '#10b981' : '#3b82f6'};transition:width 0.3s;" title="${p.lyricsCached} lyrics cached"></div>
|
||||
</div>
|
||||
<span style="font-size:0.85rem;color:var(--text-secondary);font-weight:500;min-width:40px;">${p.lyricsPercentage}%</span>
|
||||
</div>
|
||||
` : '<span style="color:var(--text-secondary);font-size:0.85rem;">-</span>'}
|
||||
</td>
|
||||
<td class="cache-age">${p.cacheAge || '-'}</td>
|
||||
<td>
|
||||
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')">Match Tracks</button>
|
||||
<button onclick="prefetchLyrics('${escapeJs(p.name)}')">Prefetch Lyrics</button>
|
||||
<button onclick="viewTracks('${escapeJs(p.name)}')">View</button>
|
||||
<button class="danger" onclick="removePlaylist('${escapeJs(p.name)}')">Remove</button>
|
||||
</td>
|
||||
@@ -1568,17 +1580,50 @@
|
||||
}
|
||||
}
|
||||
|
||||
function searchProvider(query, provider) {
|
||||
// Provider-specific search URLs
|
||||
const searchUrls = {
|
||||
'Deezer': `https://www.deezer.com/search/${encodeURIComponent(query)}`,
|
||||
'Qobuz': `https://www.qobuz.com/us-en/search?q=${encodeURIComponent(query)}`,
|
||||
'SquidWTF': `https://triton.squid.wtf/search/?s=${encodeURIComponent(query)}`,
|
||||
'default': `https://www.google.com/search?q=${encodeURIComponent(query + ' music')}`
|
||||
async function prefetchLyrics(name) {
|
||||
try {
|
||||
showToast(`Prefetching lyrics for ${name}...`, 'info', 5000);
|
||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/prefetch-lyrics`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
const summary = `Fetched: ${data.fetched}, Cached: ${data.cached}, Missing: ${data.missing}`;
|
||||
showToast(`✓ Lyrics prefetch complete for ${name}. ${summary}`, 'success', 8000);
|
||||
} else {
|
||||
showToast(data.error || 'Failed to prefetch lyrics', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to prefetch lyrics', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function searchProvider(query, provider) {
|
||||
// Use SquidWTF HiFi API with round-robin base URLs for all searches
|
||||
// Get a random base URL from the backend
|
||||
try {
|
||||
const response = await fetch('/api/admin/squidwtf-base-url');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.baseUrl) {
|
||||
// Use the HiFi API search endpoint: /search/?s=query
|
||||
const searchUrl = `${data.baseUrl}/search/?s=${encodeURIComponent(query)}`;
|
||||
window.open(searchUrl, '_blank');
|
||||
} else {
|
||||
showToast('Failed to get search URL', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to get search URL', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function capitalizeProvider(provider) {
|
||||
// Capitalize provider names for display
|
||||
const providerMap = {
|
||||
'squidwtf': 'SquidWTF',
|
||||
'deezer': 'Deezer',
|
||||
'qobuz': 'Qobuz'
|
||||
};
|
||||
|
||||
const url = searchUrls[provider] || searchUrls['default'];
|
||||
window.open(url, '_blank');
|
||||
return providerMap[provider?.toLowerCase()] || provider;
|
||||
}
|
||||
|
||||
async function clearCache() {
|
||||
@@ -1788,10 +1833,18 @@
|
||||
|
||||
if (t.isLocal === true) {
|
||||
statusBadge = '<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
|
||||
// Add manual mapping indicator for local tracks
|
||||
if (t.isManualMapping && t.manualMappingType === 'jellyfin') {
|
||||
statusBadge += '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:var(--info);color:white;"><span class="status-dot" style="background:white;"></span>Manual</span>';
|
||||
}
|
||||
} else if (t.isLocal === false) {
|
||||
const provider = t.externalProvider || 'External';
|
||||
const provider = capitalizeProvider(t.externalProvider) || 'External';
|
||||
statusBadge = `<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(provider)}</span>`;
|
||||
// Add manual map button for external tracks using data attributes
|
||||
// Add manual mapping indicator for external tracks
|
||||
if (t.isManualMapping && t.manualMappingType === 'external') {
|
||||
statusBadge += '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:var(--info);color:white;"><span class="status-dot" style="background:white;"></span>Manual</span>';
|
||||
}
|
||||
// Add both mapping buttons for external tracks using data attributes
|
||||
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
||||
mapButton = `<button class="small map-track-btn"
|
||||
data-playlist-name="${escapeHtml(name)}"
|
||||
@@ -1799,7 +1852,14 @@
|
||||
data-title="${escapeHtml(t.title || '')}"
|
||||
data-artist="${escapeHtml(firstArtist)}"
|
||||
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||
style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>`;
|
||||
style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>
|
||||
<button class="small map-external-btn"
|
||||
data-playlist-name="${escapeHtml(name)}"
|
||||
data-position="${t.position}"
|
||||
data-title="${escapeHtml(t.title || '')}"
|
||||
data-artist="${escapeHtml(firstArtist)}"
|
||||
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||
style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>`;
|
||||
} else {
|
||||
// isLocal is null/undefined - track is missing (not found locally or externally)
|
||||
statusBadge = '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;background:var(--bg-tertiary);color:var(--text-secondary);"><span class="status-dot" style="background:var(--text-secondary);"></span>Missing</span>';
|
||||
@@ -1831,7 +1891,8 @@
|
||||
<div class="track-meta">
|
||||
${t.album ? escapeHtml(t.album) : ''}
|
||||
${t.isrc ? '<br><small>ISRC: ' + t.isrc + '</small>' : ''}
|
||||
${t.isLocal === false && t.searchQuery && t.externalProvider ? '<br><small style="color:var(--accent)"><a href="#" onclick="searchProvider(\'' + escapeJs(t.searchQuery) + '\', \'' + escapeJs(t.externalProvider) + '\'); return false;" style="color:var(--accent);text-decoration:underline;">🔍 Search: ' + escapeHtml(t.searchQuery) + '</a></small>' : ''}
|
||||
${t.isLocal === false && t.searchQuery && t.externalProvider ? '<br><small style="color:var(--accent)"><a href="#" onclick="searchProvider(\'' + escapeJs(t.searchQuery) + '\', \'' + escapeJs(t.externalProvider) + '\'); return false;" style="color:var(--accent);text-decoration:underline;">🔍 Search</a></small>' : ''}
|
||||
${t.isLocal === null && t.searchQuery ? '<br><small style="color:var(--text-secondary)"><a href="#" onclick="searchProvider(\'' + escapeJs(t.searchQuery) + '\', \'squidwtf\'); return false;" style="color:var(--text-secondary);text-decoration:underline;">🔍 Search</a></small>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -2243,7 +2304,8 @@
|
||||
} else if (titleEl && mappingType === 'external') {
|
||||
// For external mappings, update status badge to show provider
|
||||
const currentTitle = titleEl.textContent.split(' - ')[0]; // Remove old status
|
||||
const newStatusBadge = `<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(requestBody.externalProvider)}</span>`;
|
||||
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>`;
|
||||
titleEl.innerHTML = escapeHtml(currentTitle) + newStatusBadge;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user