Fix: Add UserId parameter for Jellyfin playlist operations + UI improvements

- CRITICAL FIX: Add UserId parameter to all Jellyfin playlist item fetches (fixes 400 BadRequest errors)
- Fix GetPlaylists to correctly count local/missing tracks
- Fix GetSpotifyPlaylistTracksOrderedAsync to find local tracks (was serving external tracks for everything)
- Fix SpotifyTrackMatchingService to skip tracks already in Jellyfin
- Add detailed debug logging for track matching (LOCAL by ISRC/Spotify ID, EXTERNAL match, NO MATCH)
- Add 'Match Tracks' button for individual playlists (not all playlists)
- Add 'Match All Tracks' button for matching all playlists at once
- Add JELLYFIN_USER_ID to web UI configuration tab for easy setup
- Add /api/admin/playlists/match-all endpoint

This fixes the issue where local tracks weren't being used - the system was downloading
from SquidWTF even when files existed locally in Jellyfin.
This commit is contained in:
2026-02-03 18:14:13 -05:00
parent b16d16c9c9
commit c44be48eb9
4 changed files with 219 additions and 29 deletions

View File

@@ -201,35 +201,64 @@ public class AdminController : ControllerBase
{
try
{
var url = $"{_jellyfinSettings.Url}/Playlists/{config.JellyfinId}/Items?Fields=Path";
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
// Jellyfin requires UserId parameter to fetch playlist items
var userId = _jellyfinSettings.UserId;
_logger.LogDebug("Fetching Jellyfin playlist items for {Name} from {Url}", config.Name, url);
var response = await _jellyfinHttpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
// If no user configured, try to get the first user
if (string.IsNullOrEmpty(userId))
{
var jellyfinJson = await response.Content.ReadAsStringAsync();
using var jellyfinDoc = JsonDocument.Parse(jellyfinJson);
var usersResponse = await _jellyfinHttpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, $"{_jellyfinSettings.Url}/Users")
{
Headers = { { "X-Emby-Authorization", GetJellyfinAuthHeader() } }
});
if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items))
if (usersResponse.IsSuccessStatusCode)
{
var localCount = items.GetArrayLength();
playlistInfo["localTracks"] = localCount;
playlistInfo["externalTracks"] = Math.Max(0, spotifyTrackCount - localCount);
_logger.LogDebug("Playlist {Name}: {Local} local tracks, {Missing} missing",
config.Name, localCount, spotifyTrackCount - localCount);
}
else
{
_logger.LogWarning("No Items property in Jellyfin response for {Name}", config.Name);
var usersJson = await usersResponse.Content.ReadAsStringAsync();
using var usersDoc = JsonDocument.Parse(usersJson);
if (usersDoc.RootElement.GetArrayLength() > 0)
{
userId = usersDoc.RootElement[0].GetProperty("Id").GetString();
}
}
}
if (string.IsNullOrEmpty(userId))
{
_logger.LogWarning("No user ID available to fetch playlist items for {Name}", config.Name);
}
else
{
_logger.LogWarning("Failed to get Jellyfin playlist {Name}: {StatusCode}",
config.Name, response.StatusCode);
var url = $"{_jellyfinSettings.Url}/Playlists/{config.JellyfinId}/Items?UserId={userId}&Fields=Path";
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
_logger.LogDebug("Fetching Jellyfin playlist items for {Name} from {Url}", config.Name, url);
var response = await _jellyfinHttpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var jellyfinJson = await response.Content.ReadAsStringAsync();
using var jellyfinDoc = JsonDocument.Parse(jellyfinJson);
if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items))
{
var localCount = items.GetArrayLength();
playlistInfo["localTracks"] = localCount;
playlistInfo["externalTracks"] = Math.Max(0, spotifyTrackCount - localCount);
_logger.LogDebug("Playlist {Name}: {Local} local tracks, {Missing} missing",
config.Name, localCount, spotifyTrackCount - localCount);
}
else
{
_logger.LogWarning("No Items property in Jellyfin response for {Name}", config.Name);
}
}
else
{
_logger.LogWarning("Failed to get Jellyfin playlist {Name}: {StatusCode}",
config.Name, response.StatusCode);
}
}
}
catch (Exception ex)
@@ -302,7 +331,7 @@ public class AdminController : ControllerBase
try
{
await _matchingService.TriggerMatchingAsync();
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
return Ok(new { message = $"Track matching triggered for {decodedName}", timestamp = DateTime.UtcNow });
}
catch (Exception ex)
@@ -312,6 +341,31 @@ public class AdminController : ControllerBase
}
}
/// <summary>
/// Trigger track matching for all playlists
/// </summary>
[HttpPost("playlists/match-all")]
public async Task<IActionResult> MatchAllPlaylistTracks()
{
_logger.LogInformation("Manual track matching triggered for all playlists");
if (_matchingService == null)
{
return BadRequest(new { error = "Track matching service is not available" });
}
try
{
await _matchingService.TriggerMatchingAsync();
return Ok(new { message = "Track matching triggered for all playlists", timestamp = DateTime.UtcNow });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to trigger track matching for all playlists");
return StatusCode(500, new { error = "Failed to trigger track matching", details = ex.Message });
}
}
/// <summary>
/// Get current configuration (safe values only)
/// </summary>
@@ -346,6 +400,7 @@ public class AdminController : ControllerBase
{
url = _jellyfinSettings.Url,
apiKey = MaskValue(_jellyfinSettings.ApiKey),
userId = _jellyfinSettings.UserId ?? "(not set)",
libraryId = _jellyfinSettings.LibraryId
},
deezer = new

View File

@@ -2856,8 +2856,21 @@ public class JellyfinController : ControllerBase
orderedTracks.Count, spotifyPlaylistName);
// Get existing Jellyfin playlist items (tracks the Spotify Import plugin already found)
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
var userId = _settings.UserId;
if (string.IsNullOrEmpty(userId))
{
_logger.LogWarning("No UserId configured - attempting to fetch existing playlist tracks may fail");
}
var playlistItemsUrl = $"Playlists/{playlistId}/Items";
if (!string.IsNullOrEmpty(userId))
{
playlistItemsUrl += $"?UserId={userId}";
}
var (existingTracksResponse, _) = await _proxyService.GetJsonAsync(
$"Playlists/{playlistId}/Items",
playlistItemsUrl,
null,
Request.Headers);
@@ -2881,6 +2894,7 @@ public class JellyfinController : ControllerBase
if (!string.IsNullOrEmpty(spotifyId))
{
existingBySpotifyId[spotifyId] = song;
_logger.LogDebug("Indexed local track by Spotify ID: {SpotifyId} -> {Title}", spotifyId, song.Title);
}
}
@@ -2888,11 +2902,16 @@ public class JellyfinController : ControllerBase
if (!string.IsNullOrEmpty(song.Isrc))
{
existingByIsrc[song.Isrc] = song;
_logger.LogDebug("Indexed local track by ISRC: {Isrc} -> {Title}", song.Isrc, song.Title);
}
}
_logger.LogInformation("Found {Count} existing tracks in Jellyfin playlist ({SpotifyIds} with Spotify IDs, {Isrcs} with ISRCs)",
existingTracks.Count, existingBySpotifyId.Count, existingByIsrc.Count);
}
else
{
_logger.LogWarning("No existing tracks found in Jellyfin playlist {PlaylistId} - may need UserId parameter", playlistId);
}
// Get the full playlist from Spotify to know the correct order
var spotifyTracks = await _spotifyPlaylistFetcher!.GetPlaylistTracksAsync(spotifyPlaylistName);
@@ -2915,12 +2934,16 @@ public class JellyfinController : ControllerBase
if (existingBySpotifyId.TryGetValue(spotifyTrack.SpotifyId, out var trackBySpotifyId))
{
localTrack = trackBySpotifyId;
_logger.LogDebug("#{Pos} {Title} - Found LOCAL by Spotify ID: {SpotifyId}",
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.SpotifyId);
}
// Try to find by ISRC (most reliable for matching)
else if (!string.IsNullOrEmpty(spotifyTrack.Isrc) &&
existingByIsrc.TryGetValue(spotifyTrack.Isrc, out var trackByIsrc))
{
localTrack = trackByIsrc;
_logger.LogDebug("#{Pos} {Title} - Found LOCAL by ISRC: {Isrc}",
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.Isrc);
}
// If we found a local track, use it
@@ -2937,6 +2960,14 @@ public class JellyfinController : ControllerBase
{
finalTracks.Add(matched.MatchedSong);
externalUsedCount++;
_logger.LogDebug("#{Pos} {Title} - Using EXTERNAL match: {Provider}/{Id}",
spotifyTrack.Position, spotifyTrack.Title,
matched.MatchedSong.ExternalProvider, matched.MatchedSong.ExternalId);
}
else
{
_logger.LogDebug("#{Pos} {Title} - NO MATCH (skipping)",
spotifyTrack.Position, spotifyTrack.Title);
}
// If no match, the track is simply omitted (not available from any source)
}
@@ -2986,8 +3017,20 @@ public class JellyfinController : ControllerBase
}
// Get existing Jellyfin playlist items (tracks the plugin already found)
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
var userId = _settings.UserId;
var playlistItemsUrl = $"Playlists/{playlistId}/Items";
if (!string.IsNullOrEmpty(userId))
{
playlistItemsUrl += $"?UserId={userId}";
}
else
{
_logger.LogWarning("No UserId configured - may not be able to fetch existing playlist tracks");
}
var (existingTracksResponse, _) = await _proxyService.GetJsonAsync(
$"Playlists/{playlistId}/Items",
playlistItemsUrl,
null,
Request.Headers);
@@ -3011,6 +3054,10 @@ public class JellyfinController : ControllerBase
}
_logger.LogInformation("Found {Count} existing tracks in Jellyfin playlist", existingTracks.Count);
}
else
{
_logger.LogWarning("No existing tracks found in Jellyfin playlist - may need UserId parameter");
}
var missingTracksKey = $"spotify:missing:{spotifyPlaylistName}";
var missingTracks = await _cache.GetAsync<List<MissingTrack>>(missingTracksKey);

View File

@@ -87,13 +87,61 @@ public class SpotifyTrackMatchingService : BackgroundService
}
/// <summary>
/// Public method to trigger matching manually (called from controller).
/// Public method to trigger matching manually for all playlists (called from controller).
/// </summary>
public async Task TriggerMatchingAsync()
{
_logger.LogInformation("Manual track matching triggered");
_logger.LogInformation("Manual track matching triggered for all playlists");
await MatchAllPlaylistsAsync(CancellationToken.None);
}
/// <summary>
/// Public method to trigger matching for a specific playlist (called from controller).
/// </summary>
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
{
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist}", playlistName);
var playlist = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
if (playlist == null)
{
_logger.LogWarning("Playlist {Playlist} not found in configuration", playlistName);
return;
}
using var scope = _serviceProvider.CreateScope();
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
// Check if we should use the new SpotifyPlaylistFetcher
SpotifyPlaylistFetcher? playlistFetcher = null;
if (_spotifyApiSettings.Enabled)
{
playlistFetcher = scope.ServiceProvider.GetService<SpotifyPlaylistFetcher>();
}
try
{
if (playlistFetcher != null)
{
// Use new direct API mode with ISRC support
await MatchPlaylistTracksWithIsrcAsync(
playlist.Name, playlistFetcher, metadataService, CancellationToken.None);
}
else
{
// Fall back to legacy mode
await MatchPlaylistTracksLegacyAsync(
playlist.Name, metadataService, CancellationToken.None);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name);
throw;
}
}
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
{
@@ -176,13 +224,26 @@ public class SpotifyTrackMatchingService : BackgroundService
// Get existing tracks from Jellyfin playlist to avoid re-matching
using var scope = _serviceProvider.CreateScope();
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
var jellyfinSettings = scope.ServiceProvider.GetService<IOptions<JellyfinSettings>>()?.Value;
if (proxyService != null)
if (proxyService != null && jellyfinSettings != null)
{
try
{
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
var userId = jellyfinSettings.UserId;
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
if (!string.IsNullOrEmpty(userId))
{
playlistItemsUrl += $"?UserId={userId}";
}
else
{
_logger.LogWarning("No UserId configured - may not be able to fetch existing playlist tracks for {Playlist}", playlistName);
}
var (existingTracksResponse, _) = await proxyService.GetJsonAsync(
$"Playlists/{playlistConfig.JellyfinId}/Items",
playlistItemsUrl,
null,
null);
@@ -204,6 +265,10 @@ public class SpotifyTrackMatchingService : BackgroundService
_logger.LogInformation("Found {Count} tracks already in Jellyfin playlist {Playlist}, will skip matching these",
existingSpotifyIds.Count, playlistName);
}
else
{
_logger.LogWarning("No Items found in Jellyfin playlist response for {Playlist} - may need UserId parameter", playlistName);
}
}
catch (Exception ex)
{

View File

@@ -646,6 +646,7 @@
<h2>
Active Spotify Playlists
<div class="actions">
<button onclick="matchAllPlaylists()">Match All Tracks</button>
<button onclick="refreshPlaylists()">Refresh All</button>
</div>
</h2>
@@ -665,7 +666,7 @@
</thead>
<tbody id="playlist-table-body">
<tr>
<td colspan="7" class="loading">
<td colspan="6" class="loading">
<span class="spinner"></span> Loading playlists...
</td>
</tr>
@@ -762,6 +763,11 @@
<span class="value" id="config-jellyfin-api-key">-</span>
<button onclick="openEditSetting('JELLYFIN_API_KEY', 'Jellyfin API Key', 'password')">Update</button>
</div>
<div class="config-item">
<span class="label">User ID</span>
<span class="value" id="config-jellyfin-user-id">-</span>
<button onclick="openEditSetting('JELLYFIN_USER_ID', 'Jellyfin User ID', 'text', 'Required for playlist operations. Get from Jellyfin user profile URL: userId=...')">Edit</button>
</div>
<div class="config-item">
<span class="label">Library ID</span>
<span class="value" id="config-jellyfin-library-id">-</span>
@@ -1126,6 +1132,7 @@
// Jellyfin settings
document.getElementById('config-jellyfin-url').textContent = data.jellyfin.url || '-';
document.getElementById('config-jellyfin-api-key').textContent = data.jellyfin.apiKey;
document.getElementById('config-jellyfin-user-id').textContent = data.jellyfin.userId || '(not set)';
document.getElementById('config-jellyfin-library-id').textContent = data.jellyfin.libraryId || '-';
// Sync settings
@@ -1347,6 +1354,22 @@
}
}
async function matchAllPlaylists() {
try {
showToast('Matching tracks for all playlists...', 'success');
const res = await fetch('/api/admin/playlists/match-all', { method: 'POST' });
const data = await res.json();
if (res.ok) {
showToast(data.message, 'success');
} else {
showToast(data.error || 'Failed to match tracks', 'error');
}
} catch (error) {
showToast('Failed to match tracks', 'error');
}
}
async function clearCache() {
if (!confirm('Clear all cached playlist data?')) return;