mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user