mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Compare commits
6 Commits
f135db3f60
...
6ea03b8005
| Author | SHA1 | Date | |
|---|---|---|---|
|
6ea03b8005
|
|||
|
1369d09cbd
|
|||
|
838151741f
|
|||
|
88bf083386
|
|||
|
670544a9d6
|
|||
|
0dca6b792d
|
@@ -1528,6 +1528,12 @@ public class AdminController : ControllerBase
|
|||||||
|
|
||||||
_logger.LogInformation("Config file updated successfully at {Path}", _envFilePath);
|
_logger.LogInformation("Config file updated successfully at {Path}", _envFilePath);
|
||||||
|
|
||||||
|
// Invalidate playlist summary cache if playlists were updated
|
||||||
|
if (appliedUpdates.Contains("SPOTIFY_IMPORT_PLAYLISTS"))
|
||||||
|
{
|
||||||
|
InvalidatePlaylistSummaryCache();
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
message = "Configuration updated. Restart container to apply changes.",
|
message = "Configuration updated. Restart container to apply changes.",
|
||||||
@@ -1939,12 +1945,6 @@ public class AdminController : ControllerBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var token = await _spotifyClient.GetWebAccessTokenAsync();
|
|
||||||
if (string.IsNullOrEmpty(token))
|
|
||||||
{
|
|
||||||
return StatusCode(401, new { error = "Failed to authenticate with Spotify. Check your sp_dc cookie." });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get list of already-configured Spotify playlist IDs
|
// Get list of already-configured Spotify playlist IDs
|
||||||
var configuredPlaylists = await ReadPlaylistsFromEnvFile();
|
var configuredPlaylists = await ReadPlaylistsFromEnvFile();
|
||||||
var linkedSpotifyIds = new HashSet<string>(
|
var linkedSpotifyIds = new HashSet<string>(
|
||||||
@@ -1952,82 +1952,24 @@ public class AdminController : ControllerBase
|
|||||||
StringComparer.OrdinalIgnoreCase
|
StringComparer.OrdinalIgnoreCase
|
||||||
);
|
);
|
||||||
|
|
||||||
var playlists = new List<object>();
|
// Use SpotifyApiClient's GraphQL method - much less rate-limited than REST API
|
||||||
var offset = 0;
|
var spotifyPlaylists = await _spotifyClient.GetUserPlaylistsAsync(searchName: null);
|
||||||
const int limit = 50;
|
|
||||||
|
|
||||||
while (true)
|
if (spotifyPlaylists == null || spotifyPlaylists.Count == 0)
|
||||||
{
|
{
|
||||||
var url = $"https://api.spotify.com/v1/me/playlists?offset={offset}&limit={limit}";
|
return Ok(new { playlists = new List<object>() });
|
||||||
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
|
||||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
|
||||||
|
|
||||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Spotify rate limit hit (429) when fetching playlists");
|
|
||||||
return StatusCode(429, new { error = "Spotify rate limit exceeded. Please wait a moment and try again." });
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogWarning("Failed to fetch Spotify playlists: {StatusCode}", response.StatusCode);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
|
||||||
using var doc = JsonDocument.Parse(json);
|
|
||||||
var root = doc.RootElement;
|
|
||||||
|
|
||||||
if (!root.TryGetProperty("items", out var items) || items.GetArrayLength() == 0)
|
|
||||||
break;
|
|
||||||
|
|
||||||
foreach (var item in items.EnumerateArray())
|
|
||||||
{
|
|
||||||
var id = item.TryGetProperty("id", out var itemId) ? itemId.GetString() : null;
|
|
||||||
var name = item.TryGetProperty("name", out var n) ? n.GetString() : null;
|
|
||||||
var trackCount = 0;
|
|
||||||
|
|
||||||
if (item.TryGetProperty("tracks", out var tracks) &&
|
|
||||||
tracks.TryGetProperty("total", out var total))
|
|
||||||
{
|
|
||||||
trackCount = total.GetInt32();
|
|
||||||
}
|
|
||||||
|
|
||||||
var owner = "";
|
|
||||||
if (item.TryGetProperty("owner", out var ownerObj) &&
|
|
||||||
ownerObj.TryGetProperty("display_name", out var displayName))
|
|
||||||
{
|
|
||||||
owner = displayName.GetString() ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
var isPublic = item.TryGetProperty("public", out var pub) && pub.GetBoolean();
|
|
||||||
|
|
||||||
// Check if this playlist is already linked
|
|
||||||
var isLinked = !string.IsNullOrEmpty(id) && linkedSpotifyIds.Contains(id);
|
|
||||||
|
|
||||||
playlists.Add(new
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
trackCount,
|
|
||||||
owner,
|
|
||||||
isPublic,
|
|
||||||
isLinked
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (items.GetArrayLength() < limit) break;
|
|
||||||
offset += limit;
|
|
||||||
|
|
||||||
// Rate limiting
|
|
||||||
if (_spotifyApiSettings.RateLimitDelayMs > 0)
|
|
||||||
{
|
|
||||||
await Task.Delay(_spotifyApiSettings.RateLimitDelayMs);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var playlists = spotifyPlaylists.Select(p => new
|
||||||
|
{
|
||||||
|
id = p.SpotifyId,
|
||||||
|
name = p.Name,
|
||||||
|
trackCount = p.TotalTracks,
|
||||||
|
owner = p.OwnerName ?? "",
|
||||||
|
isPublic = p.Public,
|
||||||
|
isLinked = linkedSpotifyIds.Contains(p.SpotifyId)
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
return Ok(new { playlists });
|
return Ok(new { playlists });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -2108,11 +2050,16 @@ public class AdminController : ControllerBase
|
|||||||
trackStats = await GetPlaylistTrackStats(id!);
|
trackStats = await GetPlaylistTrackStats(id!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use actual track stats for configured playlists, otherwise use Jellyfin's count
|
||||||
|
var actualTrackCount = isConfigured
|
||||||
|
? trackStats.LocalTracks + trackStats.ExternalTracks
|
||||||
|
: childCount;
|
||||||
|
|
||||||
playlists.Add(new
|
playlists.Add(new
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
trackCount = childCount,
|
trackCount = actualTrackCount,
|
||||||
linkedSpotifyId,
|
linkedSpotifyId,
|
||||||
isConfigured,
|
isConfigured,
|
||||||
localTracks = trackStats.LocalTracks,
|
localTracks = trackStats.LocalTracks,
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ public class EndpointBenchmarkService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Benchmarks a list of endpoints by making test requests.
|
/// Benchmarks a list of endpoints by making test requests.
|
||||||
/// Returns endpoints sorted by average response time (fastest first).
|
/// Returns endpoints sorted by average response time (fastest first).
|
||||||
|
///
|
||||||
|
/// IMPORTANT: The testFunc should implement its own timeout to prevent slow endpoints
|
||||||
|
/// from blocking startup. Recommended: 5-10 second timeout per ping.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<List<string>> BenchmarkEndpointsAsync(
|
public async Task<List<string>> BenchmarkEndpointsAsync(
|
||||||
List<string> endpoints,
|
List<string> endpoints,
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
_logger.LogDebug("Session created for {DeviceId}", deviceId);
|
_logger.LogDebug("Session created for {DeviceId}", deviceId);
|
||||||
|
|
||||||
// Track this session
|
// Track this session
|
||||||
|
var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
|
||||||
|
?? headers["X-Real-IP"].FirstOrDefault()
|
||||||
|
?? "Unknown";
|
||||||
|
|
||||||
_sessions[deviceId] = new SessionInfo
|
_sessions[deviceId] = new SessionInfo
|
||||||
{
|
{
|
||||||
DeviceId = deviceId,
|
DeviceId = deviceId,
|
||||||
@@ -92,7 +96,8 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
Device = device,
|
Device = device,
|
||||||
Version = version,
|
Version = version,
|
||||||
LastActivity = DateTime.UtcNow,
|
LastActivity = DateTime.UtcNow,
|
||||||
Headers = CloneHeaders(headers)
|
Headers = CloneHeaders(headers),
|
||||||
|
ClientIp = clientIp
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start a WebSocket connection to Jellyfin on behalf of this client
|
// Start a WebSocket connection to Jellyfin on behalf of this client
|
||||||
@@ -222,6 +227,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
Client = s.Client,
|
Client = s.Client,
|
||||||
Device = s.Device,
|
Device = s.Device,
|
||||||
Version = s.Version,
|
Version = s.Version,
|
||||||
|
ClientIp = s.ClientIp,
|
||||||
LastActivity = s.LastActivity,
|
LastActivity = s.LastActivity,
|
||||||
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
|
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
|
||||||
HasWebSocket = s.WebSocket != null,
|
HasWebSocket = s.WebSocket != null,
|
||||||
@@ -565,6 +571,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
public ClientWebSocket? WebSocket { get; set; }
|
public ClientWebSocket? WebSocket { get; set; }
|
||||||
public string? LastPlayingItemId { get; set; }
|
public string? LastPlayingItemId { get; set; }
|
||||||
public long? LastPlayingPositionTicks { get; set; }
|
public long? LastPlayingPositionTicks { get; set; }
|
||||||
|
public string? ClientIp { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@@ -349,6 +349,17 @@ public class SpotifyApiClient : IDisposable
|
|||||||
|
|
||||||
var response = await _webApiClient.SendAsync(request, cancellationToken);
|
var response = await _webApiClient.SendAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
// Handle 429 rate limiting with exponential backoff
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||||||
|
{
|
||||||
|
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
|
||||||
|
_logger.LogWarning("Spotify rate limit hit (429) when fetching playlist {PlaylistId}. Waiting {Seconds}s before retry...", playlistId, retryAfter.TotalSeconds);
|
||||||
|
await Task.Delay(retryAfter, cancellationToken);
|
||||||
|
|
||||||
|
// Retry the request
|
||||||
|
response = await _webApiClient.SendAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
_logger.LogError("Failed to fetch playlist via GraphQL: {StatusCode}", response.StatusCode);
|
_logger.LogError("Failed to fetch playlist via GraphQL: {StatusCode}", response.StatusCode);
|
||||||
@@ -735,6 +746,18 @@ public class SpotifyApiClient : IDisposable
|
|||||||
public async Task<List<SpotifyPlaylist>> SearchUserPlaylistsAsync(
|
public async Task<List<SpotifyPlaylist>> SearchUserPlaylistsAsync(
|
||||||
string searchName,
|
string searchName,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetUserPlaylistsAsync(searchName, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all playlists from the user's library, optionally filtered by name.
|
||||||
|
/// Uses GraphQL API which is less rate-limited than REST API.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="searchName">Optional name filter (case-insensitive). If null, returns all playlists.</param>
|
||||||
|
public async Task<List<SpotifyPlaylist>> GetUserPlaylistsAsync(
|
||||||
|
string? searchName = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var token = await GetWebAccessTokenAsync(cancellationToken);
|
var token = await GetWebAccessTokenAsync(cancellationToken);
|
||||||
if (string.IsNullOrEmpty(token))
|
if (string.IsNullOrEmpty(token))
|
||||||
@@ -752,56 +775,33 @@ public class SpotifyApiClient : IDisposable
|
|||||||
|
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
// GraphQL query to fetch user playlists
|
// GraphQL query to fetch user playlists - using libraryV3 operation
|
||||||
var graphqlQuery = new
|
var queryParams = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
operationName = "fetchLibraryPlaylists",
|
{ "operationName", "libraryV3" },
|
||||||
variables = new
|
{ "variables", $"{{\"filters\":[\"Playlists\",\"By Spotify\"],\"order\":null,\"textFilter\":\"\",\"features\":[\"LIKED_SONGS\",\"YOUR_EPISODES\"],\"offset\":{offset},\"limit\":{limit}}}" },
|
||||||
{
|
{ "extensions", "{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"50650f72ea32a99b5b46240bee22fea83024eec302478a9a75cfd05a0814ba99\"}}" }
|
||||||
offset,
|
|
||||||
limit
|
|
||||||
},
|
|
||||||
query = @"
|
|
||||||
query fetchLibraryPlaylists($offset: Int!, $limit: Int!) {
|
|
||||||
me {
|
|
||||||
library {
|
|
||||||
playlists(offset: $offset, limit: $limit) {
|
|
||||||
totalCount
|
|
||||||
items {
|
|
||||||
playlist {
|
|
||||||
uri
|
|
||||||
name
|
|
||||||
description
|
|
||||||
images {
|
|
||||||
url
|
|
||||||
}
|
|
||||||
ownerV2 {
|
|
||||||
data {
|
|
||||||
__typename
|
|
||||||
... on User {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Post, $"{WebApiBase}/query")
|
var queryString = string.Join("&", queryParams.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
|
||||||
{
|
var url = $"{WebApiBase}/query?{queryString}";
|
||||||
Content = new StringContent(
|
|
||||||
JsonSerializer.Serialize(graphqlQuery),
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
System.Text.Encoding.UTF8,
|
|
||||||
"application/json")
|
|
||||||
};
|
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
var response = await _httpClient.SendAsync(request, cancellationToken);
|
var response = await _webApiClient.SendAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
// Handle 429 rate limiting with exponential backoff
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||||||
|
{
|
||||||
|
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
|
||||||
|
_logger.LogWarning("Spotify rate limit hit (429) when fetching library playlists. Waiting {Seconds}s before retry...", retryAfter.TotalSeconds);
|
||||||
|
await Task.Delay(retryAfter, cancellationToken);
|
||||||
|
|
||||||
|
// Retry the request
|
||||||
|
response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("GraphQL user playlists request failed: {StatusCode}", response.StatusCode);
|
_logger.LogWarning("GraphQL user playlists request failed: {StatusCode}", response.StatusCode);
|
||||||
@@ -814,56 +814,157 @@ public class SpotifyApiClient : IDisposable
|
|||||||
|
|
||||||
if (!root.TryGetProperty("data", out var data) ||
|
if (!root.TryGetProperty("data", out var data) ||
|
||||||
!data.TryGetProperty("me", out var me) ||
|
!data.TryGetProperty("me", out var me) ||
|
||||||
!me.TryGetProperty("library", out var library) ||
|
!me.TryGetProperty("libraryV3", out var library) ||
|
||||||
!library.TryGetProperty("playlists", out var playlistsData) ||
|
!library.TryGetProperty("items", out var items))
|
||||||
!playlistsData.TryGetProperty("items", out var items))
|
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
if (library.TryGetProperty("totalCount", out var totalCount))
|
||||||
|
{
|
||||||
|
var total = totalCount.GetInt32();
|
||||||
|
if (total == 0) break;
|
||||||
|
}
|
||||||
|
|
||||||
var itemCount = 0;
|
var itemCount = 0;
|
||||||
foreach (var item in items.EnumerateArray())
|
foreach (var item in items.EnumerateArray())
|
||||||
{
|
{
|
||||||
itemCount++;
|
itemCount++;
|
||||||
|
|
||||||
if (!item.TryGetProperty("playlist", out var playlist))
|
if (!item.TryGetProperty("item", out var playlistItem) ||
|
||||||
|
!playlistItem.TryGetProperty("data", out var playlist))
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check __typename to filter out folders and only include playlists
|
||||||
|
if (playlistItem.TryGetProperty("__typename", out var typename))
|
||||||
|
{
|
||||||
|
var typeStr = typename.GetString();
|
||||||
|
// Skip folders - only process Playlist types
|
||||||
|
if (typeStr != null && typeStr.Contains("Folder", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get playlist URI/ID
|
||||||
|
string? uri = null;
|
||||||
|
if (playlistItem.TryGetProperty("uri", out var uriProp))
|
||||||
|
{
|
||||||
|
uri = uriProp.GetString();
|
||||||
|
}
|
||||||
|
else if (playlistItem.TryGetProperty("_uri", out var uriProp2))
|
||||||
|
{
|
||||||
|
uri = uriProp2.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(uri)) continue;
|
||||||
|
|
||||||
|
// Skip if not a playlist URI (e.g., folders have different URI format)
|
||||||
|
if (!uri.StartsWith("spotify:playlist:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var spotifyId = uri.Replace("spotify:playlist:", "", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var itemName = playlist.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
var itemName = playlist.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||||
|
|
||||||
// Check if name matches (case-insensitive)
|
// Check if name matches (case-insensitive) - if searchName is provided
|
||||||
if (itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
if (!string.IsNullOrEmpty(searchName) &&
|
||||||
|
!itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var uri = playlist.TryGetProperty("uri", out var u) ? u.GetString() ?? "" : "";
|
continue;
|
||||||
var spotifyId = uri.Replace("spotify:playlist:", "", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
playlists.Add(new SpotifyPlaylist
|
|
||||||
{
|
|
||||||
SpotifyId = spotifyId,
|
|
||||||
Name = itemName,
|
|
||||||
Description = playlist.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
|
||||||
TotalTracks = 0, // GraphQL doesn't return track count in this query
|
|
||||||
SnapshotId = null
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get track count if available - try multiple possible paths
|
||||||
|
var trackCount = 0;
|
||||||
|
if (playlist.TryGetProperty("content", out var content))
|
||||||
|
{
|
||||||
|
if (content.TryGetProperty("totalCount", out var totalTrackCount))
|
||||||
|
{
|
||||||
|
trackCount = totalTrackCount.GetInt32();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: try attributes.itemCount
|
||||||
|
else if (playlist.TryGetProperty("attributes", out var attributes) &&
|
||||||
|
attributes.TryGetProperty("itemCount", out var itemCountProp))
|
||||||
|
{
|
||||||
|
trackCount = itemCountProp.GetInt32();
|
||||||
|
}
|
||||||
|
// Fallback: try totalCount directly
|
||||||
|
else if (playlist.TryGetProperty("totalCount", out var directTotalCount))
|
||||||
|
{
|
||||||
|
trackCount = directTotalCount.GetInt32();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log if we couldn't find track count for debugging
|
||||||
|
if (trackCount == 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Could not find track count for playlist {Name} (ID: {Id}). Response structure: {Json}",
|
||||||
|
itemName, spotifyId, playlist.GetRawText());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get owner name
|
||||||
|
string? ownerName = null;
|
||||||
|
if (playlist.TryGetProperty("ownerV2", out var ownerV2) &&
|
||||||
|
ownerV2.TryGetProperty("data", out var ownerData) &&
|
||||||
|
ownerData.TryGetProperty("username", out var ownerNameProp))
|
||||||
|
{
|
||||||
|
ownerName = ownerNameProp.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get image URL
|
||||||
|
string? imageUrl = null;
|
||||||
|
if (playlist.TryGetProperty("images", out var images) &&
|
||||||
|
images.TryGetProperty("items", out var imageItems) &&
|
||||||
|
imageItems.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
var firstImage = imageItems[0];
|
||||||
|
if (firstImage.TryGetProperty("sources", out var sources) &&
|
||||||
|
sources.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
var firstSource = sources[0];
|
||||||
|
if (firstSource.TryGetProperty("url", out var urlProp))
|
||||||
|
{
|
||||||
|
imageUrl = urlProp.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playlists.Add(new SpotifyPlaylist
|
||||||
|
{
|
||||||
|
SpotifyId = spotifyId,
|
||||||
|
Name = itemName,
|
||||||
|
Description = playlist.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
||||||
|
TotalTracks = trackCount,
|
||||||
|
OwnerName = ownerName,
|
||||||
|
ImageUrl = imageUrl,
|
||||||
|
SnapshotId = null
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (itemCount < limit) break;
|
if (itemCount < limit) break;
|
||||||
offset += limit;
|
offset += limit;
|
||||||
|
|
||||||
// GraphQL is less rate-limited, but still add a small delay
|
// Add delay between pages to avoid rate limiting
|
||||||
if (_settings.RateLimitDelayMs > 0)
|
// Library fetching can be aggressive, so use a longer delay
|
||||||
{
|
var delayMs = Math.Max(_settings.RateLimitDelayMs, 500); // Minimum 500ms between pages
|
||||||
await Task.Delay(_settings.RateLimitDelayMs, cancellationToken);
|
_logger.LogDebug("Waiting {DelayMs}ms before fetching next page of library playlists...", delayMs);
|
||||||
}
|
await Task.Delay(delayMs, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Found {Count} playlists matching '{SearchName}' via GraphQL", playlists.Count, searchName);
|
_logger.LogInformation("Found {Count} playlists{Filter} via GraphQL",
|
||||||
|
playlists.Count,
|
||||||
|
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
|
||||||
return playlists;
|
return playlists;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error searching user playlists for '{SearchName}' via GraphQL", searchName);
|
_logger.LogError(ex, "Error fetching user playlists{Filter} via GraphQL",
|
||||||
|
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
|
||||||
return new List<SpotifyPlaylist>();
|
return new List<SpotifyPlaylist>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,11 @@ public class SquidWTFStartupValidator : BaseStartupValidator
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var response = await _httpClient.GetAsync(endpoint, ct);
|
// 5 second timeout per ping - mark slow endpoints as failed
|
||||||
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
timeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
var response = await _httpClient.GetAsync(endpoint, timeoutCts.Token);
|
||||||
return response.IsSuccessStatusCode;
|
return response.IsSuccessStatusCode;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -1174,7 +1174,7 @@
|
|||||||
<div class="modal-content" style="max-width: 600px;">
|
<div class="modal-content" style="max-width: 600px;">
|
||||||
<h3>Map Track to External Provider</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 an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Spotify Import plugin instead.
|
Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Jellyfin mapping modal instead.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Track Info -->
|
<!-- Track Info -->
|
||||||
@@ -1216,6 +1216,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Local Jellyfin Track Mapping Modal -->
|
||||||
|
<div class="modal" id="local-map-modal">
|
||||||
|
<div class="modal-content" style="max-width: 700px;">
|
||||||
|
<h3>Map Track to Local Jellyfin Track</h3>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
|
Search your Jellyfin library and select a local track to map to this Spotify track.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Track Info -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Spotify Track (Position <span id="local-map-position"></span>)</label>
|
||||||
|
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
|
||||||
|
<strong id="local-map-spotify-title"></strong><br>
|
||||||
|
<span style="color: var(--text-secondary);" id="local-map-spotify-artist"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Section -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Search Jellyfin Library</label>
|
||||||
|
<input type="text" id="local-map-search" placeholder="Search for track name or artist...">
|
||||||
|
<button onclick="searchJellyfinTracks()" style="margin-top: 8px; width: 100%;">🔍 Search</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Results -->
|
||||||
|
<div id="local-map-results" style="max-height: 300px; overflow-y: auto; margin-top: 16px;"></div>
|
||||||
|
|
||||||
|
<input type="hidden" id="local-map-playlist-name">
|
||||||
|
<input type="hidden" id="local-map-spotify-id">
|
||||||
|
<input type="hidden" id="local-map-jellyfin-id">
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button onclick="closeModal('local-map-modal')">Cancel</button>
|
||||||
|
<button class="primary" onclick="saveLocalMapping()" id="local-map-save-btn" disabled>Save Mapping</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Link Playlist Modal -->
|
<!-- Link Playlist Modal -->
|
||||||
<div class="modal" id="link-playlist-modal">
|
<div class="modal" id="link-playlist-modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -2997,8 +3034,27 @@
|
|||||||
saveBtn.disabled = !externalId;
|
saveBtn.disabled = !externalId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open manual mapping modal (external only)
|
// Open local Jellyfin mapping modal
|
||||||
function openManualMap(playlistName, position, title, artist, spotifyId) {
|
function openManualMap(playlistName, position, title, artist, spotifyId) {
|
||||||
|
document.getElementById('local-map-playlist-name').value = playlistName;
|
||||||
|
document.getElementById('local-map-position').textContent = position + 1;
|
||||||
|
document.getElementById('local-map-spotify-title').textContent = title;
|
||||||
|
document.getElementById('local-map-spotify-artist').textContent = artist;
|
||||||
|
document.getElementById('local-map-spotify-id').value = spotifyId;
|
||||||
|
|
||||||
|
// Pre-fill search with track info
|
||||||
|
document.getElementById('local-map-search').value = `${title} ${artist}`;
|
||||||
|
|
||||||
|
// Reset fields
|
||||||
|
document.getElementById('local-map-results').innerHTML = '';
|
||||||
|
document.getElementById('local-map-jellyfin-id').value = '';
|
||||||
|
document.getElementById('local-map-save-btn').disabled = true;
|
||||||
|
|
||||||
|
openModal('local-map-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open external mapping modal
|
||||||
|
function openExternalMap(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;
|
||||||
document.getElementById('map-spotify-title').textContent = title;
|
document.getElementById('map-spotify-title').textContent = title;
|
||||||
@@ -3013,12 +3069,123 @@
|
|||||||
openModal('manual-map-modal');
|
openModal('manual-map-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alias for backward compatibility
|
// Search Jellyfin tracks for local mapping
|
||||||
function openExternalMap(playlistName, position, title, artist, spotifyId) {
|
async function searchJellyfinTracks() {
|
||||||
openManualMap(playlistName, position, title, artist, spotifyId);
|
const query = document.getElementById('local-map-search').value.trim();
|
||||||
|
if (!query) {
|
||||||
|
showToast('Please enter a search query', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultsDiv = document.getElementById('local-map-results');
|
||||||
|
resultsDiv.innerHTML = '<p style="text-align:center;padding:20px;">Searching...</p>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/jellyfin/search?query=' + encodeURIComponent(query));
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--error);padding:20px;">Search failed</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.tracks || data.tracks.length === 0) {
|
||||||
|
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:20px;">No tracks found</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsDiv.innerHTML = data.tracks.map(track => `
|
||||||
|
<div style="padding: 12px; border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; cursor: pointer; transition: background 0.2s;"
|
||||||
|
onclick="selectJellyfinTrack('${escapeJs(track.id)}', '${escapeJs(track.name)}', '${escapeJs(track.artist)}')"
|
||||||
|
onmouseover="this.style.background='var(--bg-primary)'"
|
||||||
|
onmouseout="this.style.background='transparent'">
|
||||||
|
<strong>${escapeHtml(track.name)}</strong><br>
|
||||||
|
<span style="color: var(--text-secondary); font-size: 0.9em;">${escapeHtml(track.artist)}</span>
|
||||||
|
${track.album ? '<br><span style="color: var(--text-secondary); font-size: 0.85em;">' + escapeHtml(track.album) + '</span>' : ''}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search error:', error);
|
||||||
|
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--error);padding:20px;">Search failed</p>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save manual mapping (external only)
|
// Select a Jellyfin track for mapping
|
||||||
|
function selectJellyfinTrack(jellyfinId, name, artist) {
|
||||||
|
document.getElementById('local-map-jellyfin-id').value = jellyfinId;
|
||||||
|
document.getElementById('local-map-save-btn').disabled = false;
|
||||||
|
|
||||||
|
// Highlight selected track
|
||||||
|
document.querySelectorAll('#local-map-results > div').forEach(div => {
|
||||||
|
div.style.background = 'transparent';
|
||||||
|
div.style.border = '1px solid var(--border)';
|
||||||
|
});
|
||||||
|
event.target.closest('div').style.background = 'var(--primary)';
|
||||||
|
event.target.closest('div').style.border = '1px solid var(--primary)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save local Jellyfin mapping
|
||||||
|
async function saveLocalMapping() {
|
||||||
|
const playlistName = document.getElementById('local-map-playlist-name').value;
|
||||||
|
const spotifyId = document.getElementById('local-map-spotify-id').value;
|
||||||
|
const jellyfinId = document.getElementById('local-map-jellyfin-id').value;
|
||||||
|
|
||||||
|
if (!jellyfinId) {
|
||||||
|
showToast('Please select a Jellyfin track', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
spotifyId,
|
||||||
|
jellyfinId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
const saveBtn = document.getElementById('local-map-save-btn');
|
||||||
|
const originalText = saveBtn.textContent;
|
||||||
|
saveBtn.textContent = 'Saving...';
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(playlistName)}/map`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Track mapped successfully!', 'success');
|
||||||
|
closeModal('local-map-modal');
|
||||||
|
|
||||||
|
// Refresh the tracks view if it's open
|
||||||
|
const tracksModal = document.getElementById('tracks-modal');
|
||||||
|
if (tracksModal.style.display === 'flex') {
|
||||||
|
await viewTracks(playlistName);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
showToast(data.error || 'Failed to save mapping', 'error');
|
||||||
|
saveBtn.textContent = originalText;
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
showToast('Request timed out. The mapping may still be processing.', 'warning');
|
||||||
|
} else {
|
||||||
|
showToast('Failed to save mapping', 'error');
|
||||||
|
}
|
||||||
|
saveBtn.textContent = originalText;
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save manual mapping (external only) - kept for backward compatibility
|
||||||
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;
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- allstarr-network
|
- allstarr-network
|
||||||
|
|
||||||
|
# Spotify Lyrics API sidecar service
|
||||||
|
# Note: This image only supports AMD64. On ARM64 systems, Docker will use emulation.
|
||||||
spotify-lyrics:
|
spotify-lyrics:
|
||||||
image: akashrchandran/spotify-lyrics-api:latest
|
image: akashrchandran/spotify-lyrics-api:latest
|
||||||
|
platform: linux/amd64
|
||||||
container_name: allstarr-spotify-lyrics
|
container_name: allstarr-spotify-lyrics
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
Reference in New Issue
Block a user