9 Commits

Author SHA1 Message Date
d0a7dbcc96 v1.2.2: fix metadata loss in Spotify playlists
Spotify playlist tracks were missing genres, composers, and other metadata because the proxy only requested MediaSources field instead of passing through all client-requested fields.
2026-02-10 11:01:38 -05:00
9c9a827a91 v1.2.1: MusicBrainz genre enrichment + cleanup
## Features
- Implement automatic MusicBrainz genre enrichment for all external sources
  - Deezer: Enriches when genre missing
  - Qobuz: Enriches when genre missing
  - SquidWTF/Tidal: Always enriches (Tidal doesn't provide genres)
- Use ISRC codes for exact matching, fallback to title/artist search
- Cache results in Redis (30 days) + file cache for performance
- Respect MusicBrainz rate limits (1 req/sec)

## Cleanup
- Remove unused Spotify API ClientId and ClientSecret settings
- Simplify Spotify API configuration

## Fixes
- Make GenreEnrichmentService optional to fix test failures
- All 225 tests passing

This ensures all external tracks have genre metadata for better
organization and filtering in music clients.
2026-02-10 10:29:49 -05:00
96889738df v1.2.1: MusicBrainz genre enrichment + cleanup
## Features
- Implement automatic MusicBrainz genre enrichment for all external sources
  - Deezer: Enriches when genre missing
  - Qobuz: Enriches when genre missing
  - SquidWTF/Tidal: Always enriches (Tidal doesn't provide genres)
- Use ISRC codes for exact matching, fallback to title/artist search
- Cache results in Redis (30 days) + file cache for performance
- Respect MusicBrainz rate limits (1 req/sec)

## Cleanup
- Remove unused Spotify API ClientId and ClientSecret settings
- Simplify Spotify API configuration

This ensures all external tracks have genre metadata for better
organization and filtering in music clients.
2026-02-10 10:25:41 -05:00
f3c791496e v1.2.0: Spotify playlist improvements and admin UI fixes
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
Enhanced Spotify playlist integration with GraphQL API, fixed track counts and folder filtering, improved session IP tracking with X-Forwarded-For support, and added per-playlist cron scheduling.
2026-02-09 18:17:15 -05:00
f68706f300 Release v1.1.1 - Download Structure Fix
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
Fixed cache and permanent files to use unified downloads/ structure instead of separate paths.
2026-02-08 01:51:18 -05:00
9f362b4920 Release v1.1.0 - Configuration Simplification
Configuration Changes:
- Removed sync window logic from Spotify Import (no more SYNC_START_HOUR, SYNC_START_MINUTE, SYNC_WINDOW_HOURS)
- Simplified to: fetch on startup if cache missing, check every 5 minutes for stale cache
- Unified download folder structure: downloads/{permanent,cache,kept}/ instead of separate paths
- Removed Library:KeptPath config, now uses downloads/kept/

Documentation:
- Updated README with clearer Spotify Import configuration
- Updated .env.example to reflect simplified settings
- Removed MIGRATION.md from repository (local-only file)

Bug Fixes:
- Web UI now correctly displays kept tracks in Active Playlists tab
- Fixed path handling for favorited tracks
2026-02-08 01:33:09 -05:00
2b09484c0b Release v1.0.0 - Production Ready
Major Features:
- Spotify playlist injection with missing tracks search
- Transparent proxy authentication system
- WebSocket session management for external tracks
- Manual track mapping and favorites system
- Lyrics support (Spotify + LRCLib) with prefetching
- Admin dashboard with analytics and configuration
- Performance optimizations with health checks and endpoint racing
- Comprehensive caching and memory management

Performance Improvements:
- Quick health checks (3s timeout) before trying endpoints
- Health check results cached for 30 seconds
- 5 minute timeout for large artist responses
- Background Odesli conversion after streaming starts
- Parallel lyrics prefetching
- Endpoint benchmarking and racing
- 16 SquidWTF endpoints with load balancing

Reliability:
- Automatic endpoint fallback and failover
- Token expiration handling
- Concurrent request optimization
- Memory leak fixes
- Proper session cleanup

User Experience:
- Web UI for configuration and playlist management
- Real-time progress tracking
- API analytics dashboard
- Manual track mapping interface
- Playlist statistics and health monitoring
2026-02-08 00:43:47 -05:00
fa9739bfaa docs: update README
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-01-31 11:16:00 -05:00
0ba51e2b30 fix: improve auth, search, and stability
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-01-31 01:14:53 -05:00
17 changed files with 444 additions and 207 deletions

View File

@@ -143,13 +143,6 @@ SPOTIFY_IMPORT_PLAYLISTS=[]
# Enable direct Spotify API access (default: false)
SPOTIFY_API_ENABLED=false
# Spotify Client ID from https://developer.spotify.com/dashboard
# Create an app in the Spotify Developer Dashboard to get this
SPOTIFY_API_CLIENT_ID=
# Spotify Client Secret (optional - only needed for certain OAuth flows)
SPOTIFY_API_CLIENT_SECRET=
# Spotify session cookie (sp_dc) - REQUIRED for editorial playlists
# Editorial playlists (Release Radar, Discover Weekly, etc.) require authentication
# via session cookie because they're not accessible through the official API.

View File

@@ -1528,6 +1528,12 @@ public class AdminController : ControllerBase
_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
{
message = "Configuration updated. Restart container to apply changes.",
@@ -1939,12 +1945,6 @@ public class AdminController : ControllerBase
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
var configuredPlaylists = await ReadPlaylistsFromEnvFile();
var linkedSpotifyIds = new HashSet<string>(
@@ -1952,82 +1952,24 @@ public class AdminController : ControllerBase
StringComparer.OrdinalIgnoreCase
);
var playlists = new List<object>();
var offset = 0;
const int limit = 50;
// Use SpotifyApiClient's GraphQL method - much less rate-limited than REST API
var spotifyPlaylists = await _spotifyClient.GetUserPlaylistsAsync(searchName: null);
while (true)
if (spotifyPlaylists == null || spotifyPlaylists.Count == 0)
{
var url = $"https://api.spotify.com/v1/me/playlists?offset={offset}&limit={limit}";
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);
}
return Ok(new { playlists = new List<object>() });
}
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 });
}
catch (Exception ex)
@@ -2108,11 +2050,16 @@ public class AdminController : ControllerBase
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
{
id,
name,
trackCount = childCount,
trackCount = actualTrackCount,
linkedSpotifyId,
isConfigured,
localTracks = trackStats.LocalTracks,

View File

@@ -3529,8 +3529,17 @@ public class JellyfinController : ControllerBase
return null; // Fall back to legacy mode
}
// Request MediaSources field to get bitrate info
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}&Fields=MediaSources";
// Pass through all requested fields from the original request
var queryString = Request.QueryString.Value ?? "";
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}";
// Append the original query string (which includes Fields parameter)
if (!string.IsNullOrEmpty(queryString))
{
// Remove the leading ? if present
queryString = queryString.TrimStart('?');
playlistItemsUrl = $"{playlistItemsUrl}&{queryString}";
}
_logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}",
playlistId, userId);

View File

@@ -18,18 +18,6 @@ public class SpotifyApiSettings
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Spotify Client ID from https://developer.spotify.com/dashboard
/// Used for OAuth token refresh and API access.
/// </summary>
public string ClientId { get; set; } = string.Empty;
/// <summary>
/// Spotify Client Secret from https://developer.spotify.com/dashboard
/// Optional - only needed for certain OAuth flows.
/// </summary>
public string ClientSecret { get; set; } = string.Empty;
/// <summary>
/// Spotify session cookie (sp_dc).
/// Required for accessing editorial/personalized playlists like Release Radar and Discover Weekly.

View File

@@ -473,7 +473,8 @@ else if (musicService == MusicService.SquidWTF)
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
sp.GetRequiredService<ILogger<SquidWTFMetadataService>>(),
sp.GetRequiredService<RedisCacheService>(),
squidWtfApiUrls));
squidWtfApiUrls,
sp.GetRequiredService<GenreEnrichmentService>()));
builder.Services.AddSingleton<IDownloadService>(sp =>
new SquidWTFDownloadService(
sp.GetRequiredService<IHttpClientFactory>(),
@@ -537,18 +538,6 @@ builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options
options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase);
}
var clientId = builder.Configuration.GetValue<string>("SpotifyApi:ClientId");
if (!string.IsNullOrEmpty(clientId))
{
options.ClientId = clientId;
}
var clientSecret = builder.Configuration.GetValue<string>("SpotifyApi:ClientSecret");
if (!string.IsNullOrEmpty(clientSecret))
{
options.ClientSecret = clientSecret;
}
var sessionCookie = builder.Configuration.GetValue<string>("SpotifyApi:SessionCookie");
if (!string.IsNullOrEmpty(sessionCookie))
{
@@ -576,7 +565,6 @@ builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options
// Log configuration (mask sensitive values)
Console.WriteLine($"SpotifyApi Configuration:");
Console.WriteLine($" Enabled: {options.Enabled}");
Console.WriteLine($" ClientId: {(string.IsNullOrEmpty(options.ClientId) ? "(not set)" : options.ClientId[..8] + "...")}");
Console.WriteLine($" SessionCookie: {(string.IsNullOrEmpty(options.SessionCookie) ? "(not set)" : "***" + options.SessionCookie[^8..])}");
Console.WriteLine($" SessionCookieSetDate: {options.SessionCookieSetDate ?? "(not set)"}");
Console.WriteLine($" CacheDurationMinutes: {options.CacheDurationMinutes}");

View File

@@ -20,6 +20,9 @@ public class EndpointBenchmarkService
/// <summary>
/// Benchmarks a list of endpoints by making test requests.
/// 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>
public async Task<List<string>> BenchmarkEndpointsAsync(
List<string> endpoints,

View File

@@ -3,6 +3,7 @@ using allstarr.Models.Settings;
using allstarr.Models.Download;
using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using allstarr.Services.Common;
using System.Text.Json;
using Microsoft.Extensions.Options;
@@ -15,12 +16,17 @@ public class DeezerMetadataService : IMusicMetadataService
{
private readonly HttpClient _httpClient;
private readonly SubsonicSettings _settings;
private readonly GenreEnrichmentService? _genreEnrichment;
private const string BaseUrl = "https://api.deezer.com";
public DeezerMetadataService(IHttpClientFactory httpClientFactory, IOptions<SubsonicSettings> settings)
public DeezerMetadataService(
IHttpClientFactory httpClientFactory,
IOptions<SubsonicSettings> settings,
GenreEnrichmentService? genreEnrichment = null)
{
_httpClient = httpClientFactory.CreateClient();
_settings = settings.Value;
_genreEnrichment = genreEnrichment;
}
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
@@ -203,6 +209,12 @@ public class DeezerMetadataService : IMusicMetadataService
}
}
// Enrich with MusicBrainz genres if missing
if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre))
{
await _genreEnrichment.EnrichSongGenreAsync(song);
}
return song;
}

View File

@@ -85,6 +85,10 @@ public class JellyfinSessionManager : IDisposable
_logger.LogDebug("Session created for {DeviceId}", deviceId);
// Track this session
var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
?? headers["X-Real-IP"].FirstOrDefault()
?? "Unknown";
_sessions[deviceId] = new SessionInfo
{
DeviceId = deviceId,
@@ -92,7 +96,8 @@ public class JellyfinSessionManager : IDisposable
Device = device,
Version = version,
LastActivity = DateTime.UtcNow,
Headers = CloneHeaders(headers)
Headers = CloneHeaders(headers),
ClientIp = clientIp
};
// Start a WebSocket connection to Jellyfin on behalf of this client
@@ -222,6 +227,7 @@ public class JellyfinSessionManager : IDisposable
Client = s.Client,
Device = s.Device,
Version = s.Version,
ClientIp = s.ClientIp,
LastActivity = s.LastActivity,
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
HasWebSocket = s.WebSocket != null,
@@ -565,6 +571,7 @@ public class JellyfinSessionManager : IDisposable
public ClientWebSocket? WebSocket { get; set; }
public string? LastPlayingItemId { get; set; }
public long? LastPlayingPositionTicks { get; set; }
public string? ClientIp { get; set; }
}
public void Dispose()

View File

@@ -167,16 +167,14 @@ public class LyricsStartupValidator : BaseStartupValidator
{
try
{
if (string.IsNullOrEmpty(_spotifySettings.ClientId))
if (!_spotifySettings.Enabled)
{
WriteStatus("Spotify API", "NOT CONFIGURED", ConsoleColor.Yellow);
WriteDetail("Set SpotifyApi__ClientId to enable");
WriteStatus("Spotify API", "DISABLED", ConsoleColor.Gray);
return true;
}
WriteStatus("Spotify API", "CONFIGURED", ConsoleColor.Green);
WriteDetail($"Client ID: {_spotifySettings.ClientId.Substring(0, Math.Min(8, _spotifySettings.ClientId.Length))}...");
WriteDetail("Note: Spotify API is used for track matching, not lyrics");
WriteDetail("Note: Spotify API is used for track matching and lyrics");
return true;
}
catch (Exception ex)

View File

@@ -3,6 +3,7 @@ using allstarr.Models.Settings;
using allstarr.Models.Download;
using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using allstarr.Services.Common;
using System.Text.Json;
using Microsoft.Extensions.Options;
@@ -18,6 +19,7 @@ public class QobuzMetadataService : IMusicMetadataService
private readonly SubsonicSettings _settings;
private readonly QobuzBundleService _bundleService;
private readonly ILogger<QobuzMetadataService> _logger;
private readonly GenreEnrichmentService? _genreEnrichment;
private readonly string? _userAuthToken;
private readonly string? _userId;
@@ -28,12 +30,14 @@ public class QobuzMetadataService : IMusicMetadataService
IOptions<SubsonicSettings> settings,
IOptions<QobuzSettings> qobuzSettings,
QobuzBundleService bundleService,
ILogger<QobuzMetadataService> logger)
ILogger<QobuzMetadataService> logger,
GenreEnrichmentService? genreEnrichment = null)
{
_httpClient = httpClientFactory.CreateClient();
_settings = settings.Value;
_bundleService = bundleService;
_logger = logger;
_genreEnrichment = genreEnrichment;
var qobuzConfig = qobuzSettings.Value;
_userAuthToken = qobuzConfig.UserAuthToken;
@@ -177,7 +181,15 @@ public class QobuzMetadataService : IMusicMetadataService
if (track.TryGetProperty("error", out _)) return null;
return ParseQobuzTrackFull(track);
var song = ParseQobuzTrackFull(track);
// Enrich with MusicBrainz genres if missing
if (_genreEnrichment != null && song != null && string.IsNullOrEmpty(song.Genre))
{
await _genreEnrichment.EnrichSongGenreAsync(song);
}
return song;
}
catch (Exception ex)
{

View File

@@ -349,6 +349,17 @@ public class SpotifyApiClient : IDisposable
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)
{
_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(
string searchName,
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);
if (string.IsNullOrEmpty(token))
@@ -752,56 +775,33 @@ public class SpotifyApiClient : IDisposable
while (true)
{
// GraphQL query to fetch user playlists
var graphqlQuery = new
// GraphQL query to fetch user playlists - using libraryV3 operation
var queryParams = new Dictionary<string, string>
{
operationName = "fetchLibraryPlaylists",
variables = new
{
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
}
}
}
}
}
}
}
}
}"
{ "operationName", "libraryV3" },
{ "variables", $"{{\"filters\":[\"Playlists\",\"By Spotify\"],\"order\":null,\"textFilter\":\"\",\"features\":[\"LIKED_SONGS\",\"YOUR_EPISODES\"],\"offset\":{offset},\"limit\":{limit}}}" },
{ "extensions", "{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"50650f72ea32a99b5b46240bee22fea83024eec302478a9a75cfd05a0814ba99\"}}" }
};
var request = new HttpRequestMessage(HttpMethod.Post, $"{WebApiBase}/query")
{
Content = new StringContent(
JsonSerializer.Serialize(graphqlQuery),
System.Text.Encoding.UTF8,
"application/json")
};
var queryString = string.Join("&", queryParams.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
var url = $"{WebApiBase}/query?{queryString}";
var request = new HttpRequestMessage(HttpMethod.Get, url);
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)
{
_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) ||
!data.TryGetProperty("me", out var me) ||
!me.TryGetProperty("library", out var library) ||
!library.TryGetProperty("playlists", out var playlistsData) ||
!playlistsData.TryGetProperty("items", out var items))
!me.TryGetProperty("libraryV3", out var library) ||
!library.TryGetProperty("items", out var items))
{
break;
}
// Get total count
if (library.TryGetProperty("totalCount", out var totalCount))
{
var total = totalCount.GetInt32();
if (total == 0) break;
}
var itemCount = 0;
foreach (var item in items.EnumerateArray())
{
itemCount++;
if (!item.TryGetProperty("playlist", out var playlist))
if (!item.TryGetProperty("item", out var playlistItem) ||
!playlistItem.TryGetProperty("data", out var playlist))
{
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() ?? "" : "";
// Check if name matches (case-insensitive)
if (itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
// Check if name matches (case-insensitive) - if searchName is provided
if (!string.IsNullOrEmpty(searchName) &&
!itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
{
var uri = playlist.TryGetProperty("uri", out var u) ? u.GetString() ?? "" : "";
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
});
continue;
}
// 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;
offset += limit;
// GraphQL is less rate-limited, but still add a small delay
if (_settings.RateLimitDelayMs > 0)
{
await Task.Delay(_settings.RateLimitDelayMs, cancellationToken);
}
// Add delay between pages to avoid rate limiting
// Library fetching can be aggressive, so use a longer delay
var delayMs = Math.Max(_settings.RateLimitDelayMs, 500); // Minimum 500ms between pages
_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;
}
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>();
}
}

View File

@@ -56,6 +56,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
private readonly ILogger<SquidWTFMetadataService> _logger;
private readonly RedisCacheService _cache;
private readonly RoundRobinFallbackHelper _fallbackHelper;
private readonly GenreEnrichmentService? _genreEnrichment;
public SquidWTFMetadataService(
IHttpClientFactory httpClientFactory,
@@ -63,13 +64,15 @@ public class SquidWTFMetadataService : IMusicMetadataService
IOptions<SquidWTFSettings> squidwtfSettings,
ILogger<SquidWTFMetadataService> logger,
RedisCacheService cache,
List<string> apiUrls)
List<string> apiUrls,
GenreEnrichmentService? genreEnrichment = null)
{
_httpClient = httpClientFactory.CreateClient();
_settings = settings.Value;
_logger = logger;
_cache = cache;
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
_genreEnrichment = genreEnrichment;
// Set up default headers
_httpClient.DefaultRequestHeaders.Add("User-Agent",
@@ -286,6 +289,12 @@ public class SquidWTFMetadataService : IMusicMetadataService
var song = ParseTidalTrackFull(track);
// Enrich with MusicBrainz genres if missing (SquidWTF/Tidal doesn't provide genres)
if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre))
{
await _genreEnrichment.EnrichSongGenreAsync(song);
}
// NOTE: Spotify ID conversion happens during download (in SquidWTFDownloadService)
// This avoids redundant conversions and ensures it's done in parallel with the download

View File

@@ -73,7 +73,11 @@ public class SquidWTFStartupValidator : BaseStartupValidator
{
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;
}
catch

View File

@@ -5,9 +5,9 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>allstarr</RootNamespace>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<Version>1.2.2</Version>
<AssemblyVersion>1.2.2.0</AssemblyVersion>
<FileVersion>1.2.2.0</FileVersion>
</PropertyGroup>
<ItemGroup>

View File

@@ -61,8 +61,6 @@
},
"SpotifyApi": {
"Enabled": false,
"ClientId": "",
"ClientSecret": "",
"SessionCookie": "",
"CacheDurationMinutes": 60,
"RateLimitDelayMs": 100,

View File

@@ -1174,7 +1174,7 @@
<div class="modal-content" style="max-width: 600px;">
<h3>Map Track to External Provider</h3>
<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>
<!-- Track Info -->
@@ -1216,6 +1216,43 @@
</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 -->
<div class="modal" id="link-playlist-modal">
<div class="modal-content">
@@ -2997,8 +3034,27 @@
saveBtn.disabled = !externalId;
}
// Open manual mapping modal (external only)
// Open local Jellyfin mapping modal
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-position').textContent = position + 1;
document.getElementById('map-spotify-title').textContent = title;
@@ -3013,12 +3069,123 @@
openModal('manual-map-modal');
}
// Alias for backward compatibility
function openExternalMap(playlistName, position, title, artist, spotifyId) {
openManualMap(playlistName, position, title, artist, spotifyId);
// Search Jellyfin tracks for local mapping
async function searchJellyfinTracks() {
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() {
const playlistName = document.getElementById('map-playlist-name').value;
const spotifyId = document.getElementById('map-spotify-id').value;

View File

@@ -17,8 +17,11 @@ services:
networks:
- allstarr-network
# Spotify Lyrics API sidecar service
# Note: This image only supports AMD64. On ARM64 systems, Docker will use emulation.
spotify-lyrics:
image: akashrchandran/spotify-lyrics-api:latest
platform: linux/amd64
container_name: allstarr-spotify-lyrics
restart: unless-stopped
ports:
@@ -104,8 +107,6 @@ services:
# ===== SPOTIFY DIRECT API (for lyrics, ISRC matching, track ordering) =====
- SpotifyApi__Enabled=${SPOTIFY_API_ENABLED:-false}
- SpotifyApi__ClientId=${SPOTIFY_API_CLIENT_ID:-}
- SpotifyApi__ClientSecret=${SPOTIFY_API_CLIENT_SECRET:-}
- SpotifyApi__SessionCookie=${SPOTIFY_API_SESSION_COOKIE:-}
- SpotifyApi__SessionCookieSetDate=${SPOTIFY_API_SESSION_COOKIE_SET_DATE:-}
- SpotifyApi__CacheDurationMinutes=${SPOTIFY_API_CACHE_DURATION_MINUTES:-60}