feat(jellyfin): add per-request multi-user support

This commit is contained in:
2026-04-05 12:36:11 -04:00
parent 8be544bdfc
commit dc225945f8
18 changed files with 791 additions and 168 deletions
+35
View File
@@ -100,4 +100,39 @@ public class AuthHeaderHelperTests
Assert.Contains("Version=\"1.0\"", header);
Assert.Contains("Token=\"abc\"", header);
}
[Fact]
public void ExtractAccessToken_ShouldReadMediaBrowserToken()
{
var headers = new HeaderDictionary
{
["X-Emby-Authorization"] =
"MediaBrowser Client=\"Feishin\", Device=\"Desktop\", DeviceId=\"dev-123\", Version=\"1.0\", Token=\"abc\""
};
Assert.Equal("abc", AuthHeaderHelper.ExtractAccessToken(headers));
}
[Fact]
public void ExtractAccessToken_ShouldReadBearerToken()
{
var headers = new HeaderDictionary
{
["Authorization"] = "Bearer xyz"
};
Assert.Equal("xyz", AuthHeaderHelper.ExtractAccessToken(headers));
}
[Fact]
public void ExtractUserId_ShouldReadMediaBrowserUserId()
{
var headers = new HeaderDictionary
{
["X-Emby-Authorization"] =
"MediaBrowser Client=\"Feishin\", UserId=\"user-123\", Token=\"abc\""
};
Assert.Equal("user-123", AuthHeaderHelper.ExtractUserId(headers));
}
}
+55 -2
View File
@@ -21,6 +21,7 @@ public class JellyfinProxyServiceTests
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
private readonly RedisCacheService _cache;
private readonly JellyfinSettings _settings;
private readonly IHttpContextAccessor _httpContextAccessor;
public JellyfinProxyServiceTests()
{
@@ -46,8 +47,9 @@ public class JellyfinProxyServiceTests
};
var httpContext = new DefaultHttpContext();
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
_httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
var userResolver = CreateUserContextResolver(_httpContextAccessor);
// Initialize cache settings for tests
var serviceCollection = new Microsoft.Extensions.DependencyInjection.ServiceCollection();
@@ -58,7 +60,8 @@ public class JellyfinProxyServiceTests
_service = new JellyfinProxyService(
_mockHttpClientFactory.Object,
Options.Create(_settings),
httpContextAccessor,
_httpContextAccessor,
userResolver,
mockLogger.Object,
_cache);
}
@@ -229,6 +232,44 @@ public class JellyfinProxyServiceTests
Assert.Equal("test query", searchTermValue);
}
[Fact]
public async Task SearchAsync_WithClientToken_ResolvesAndAppendsRequestUserId()
{
var requestedUris = new List<string>();
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync((HttpRequestMessage req, CancellationToken _) =>
{
requestedUris.Add(req.RequestUri!.ToString());
if (req.RequestUri!.AbsolutePath.EndsWith("/Users/Me", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"Id\":\"resolved-user\"}")
};
}
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"Items\":[],\"TotalRecordCount\":0}")
};
});
var headers = new HeaderDictionary
{
["X-Emby-Token"] = "token-123"
};
await _service.SearchAsync("test query", new[] { "Audio" }, 25, clientHeaders: headers);
Assert.Contains(requestedUris, uri => uri.EndsWith("/Users/Me"));
Assert.Contains(requestedUris, uri => uri.Contains("userId=resolved-user", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task GetItemAsync_RequestsCorrectEndpoint()
{
@@ -631,11 +672,13 @@ public class JellyfinProxyServiceTests
var redisSettings = new RedisSettings { Enabled = false };
var mockCacheLogger = new Mock<ILogger<RedisCacheService>>();
var cache = new RedisCacheService(Options.Create(redisSettings), mockCacheLogger.Object, new MemoryCache(new MemoryCacheOptions()));
var userResolver = CreateUserContextResolver(httpContextAccessor);
var service = new JellyfinProxyService(
_mockHttpClientFactory.Object,
Options.Create(_settings),
httpContextAccessor,
userResolver,
mockLogger.Object,
cache);
@@ -661,4 +704,14 @@ public class JellyfinProxyServiceTests
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response);
}
private JellyfinUserContextResolver CreateUserContextResolver(IHttpContextAccessor httpContextAccessor)
{
return new JellyfinUserContextResolver(
httpContextAccessor,
_mockHttpClientFactory.Object,
Options.Create(_settings),
new MemoryCache(new MemoryCacheOptions()),
new Mock<ILogger<JellyfinUserContextResolver>>().Object);
}
}
@@ -189,6 +189,12 @@ public class JellyfinSessionManagerTests
httpClientFactory,
Options.Create(settings),
httpContextAccessor,
new JellyfinUserContextResolver(
httpContextAccessor,
httpClientFactory,
Options.Create(settings),
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()),
NullLogger<JellyfinUserContextResolver>.Instance),
NullLogger<JellyfinProxyService>.Instance,
cache);
}
+4 -4
View File
@@ -637,11 +637,11 @@ public class ConfigController : ControllerBase
{
var keysToDelete = new[]
{
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
$"spotify:matched:{playlist.Name}", // Legacy key
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name)
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId)
};
foreach (var key in keysToDelete)
+38 -8
View File
@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text;
using System.Net.Http;
using allstarr.Models.Domain;
using allstarr.Models.Settings;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
using Microsoft.AspNetCore.Mvc;
@@ -167,7 +168,7 @@ public partial class JellyfinController
if (!spotifyPlaylistCreatedDates.TryGetValue(playlistName, out var playlistCreatedDate))
{
playlistCreatedDate = await ResolveSpotifyPlaylistCreatedDateAsync(playlistName);
playlistCreatedDate = await ResolveSpotifyPlaylistCreatedDateAsync(playlistConfig);
spotifyPlaylistCreatedDates[playlistName] = playlistCreatedDate;
}
@@ -177,7 +178,16 @@ public partial class JellyfinController
}
// Get matched external tracks (tracks that were successfully downloaded/matched)
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName);
var playlistScopeUserId = string.IsNullOrWhiteSpace(playlistConfig.UserId)
? null
: playlistConfig.UserId.Trim();
var playlistScopeId = !string.IsNullOrWhiteSpace(playlistConfig.JellyfinId)
? playlistConfig.JellyfinId
: playlistConfig.Id;
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
_logger.LogDebug("Cache lookup for {Key}: {Count} matched tracks",
@@ -186,7 +196,10 @@ public partial class JellyfinController
// Fallback to legacy cache format
if (matchedTracks == null || matchedTracks.Count == 0)
{
var legacyKey = $"spotify:matched:{playlistName}";
var legacyKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
var legacySongs = await _cache.GetAsync<List<Song>>(legacyKey);
if (legacySongs != null && legacySongs.Count > 0)
{
@@ -202,7 +215,10 @@ public partial class JellyfinController
// Prefer the currently served playlist items cache when available.
// This most closely matches what the injected playlist endpoint will return.
var exactServedCount = 0;
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlistName);
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
var cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsKey);
var exactServedRunTimeTicks = 0L;
if (cachedPlaylistItems != null &&
@@ -231,7 +247,7 @@ public partial class JellyfinController
var localRunTimeTicks = 0L;
try
{
var userId = _settings.UserId;
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
var playlistItemsUrl = $"Playlists/{playlistId}/Items";
var queryParams = new Dictionary<string, string>
{
@@ -334,11 +350,22 @@ public partial class JellyfinController
}
}
private async Task<DateTime?> ResolveSpotifyPlaylistCreatedDateAsync(string playlistName)
private async Task<DateTime?> ResolveSpotifyPlaylistCreatedDateAsync(SpotifyPlaylistConfig playlistConfig)
{
var playlistName = playlistConfig.Name;
try
{
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName);
var playlistScopeUserId = string.IsNullOrWhiteSpace(playlistConfig.UserId)
? null
: playlistConfig.UserId.Trim();
var playlistScopeId = !string.IsNullOrWhiteSpace(playlistConfig.JellyfinId)
? playlistConfig.JellyfinId
: playlistConfig.Id;
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
var cachedPlaylist = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
var createdAt = GetCreatedDateFromSpotifyPlaylist(cachedPlaylist);
if (createdAt.HasValue)
@@ -351,7 +378,10 @@ public partial class JellyfinController
return null;
}
var tracks = await _spotifyPlaylistFetcher.GetPlaylistTracksAsync(playlistName);
var tracks = await _spotifyPlaylistFetcher.GetPlaylistTracksAsync(
playlistName,
playlistScopeUserId,
playlistConfig.JellyfinId);
var earliestTrackAddedAt = tracks
.Where(t => t.AddedAt.HasValue)
.Select(t => t.AddedAt!.Value.ToUniversalTime())
@@ -1062,7 +1062,7 @@ public partial class JellyfinController
return;
}
var userId = ResolvePlaybackUserId(progressPayload);
var userId = await ResolvePlaybackUserIdAsync(progressPayload);
if (string.IsNullOrWhiteSpace(userId))
{
_logger.LogDebug("Skipping local played signal for {ItemId} - no user id available", itemId);
@@ -1098,7 +1098,7 @@ public partial class JellyfinController
}
}
private string? ResolvePlaybackUserId(JsonElement progressPayload)
private async Task<string?> ResolvePlaybackUserIdAsync(JsonElement progressPayload)
{
if (progressPayload.TryGetProperty("UserId", out var userIdElement) &&
userIdElement.ValueKind == JsonValueKind.String)
@@ -1116,7 +1116,7 @@ public partial class JellyfinController
return queryUserId;
}
return _settings.UserId;
return await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
}
private static int? ToPlaybackPositionSeconds(long? positionTicks)
@@ -57,8 +57,15 @@ public partial class JellyfinController
private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName,
string playlistId)
{
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
var playlistConfig = _spotifySettings.GetPlaylistByJellyfinId(playlistId)
?? _spotifySettings.GetPlaylistByName(spotifyPlaylistName, userId, playlistId);
var playlistScopeUserId = playlistConfig?.UserId ?? userId;
var playlistScopeId = playlistConfig?.JellyfinId ?? playlistId;
// Check if Jellyfin playlist has changed (cheap API call)
var jellyfinSignatureCacheKey = $"spotify:playlist:jellyfin-signature:{spotifyPlaylistName}";
var jellyfinSignatureCacheKey =
$"spotify:playlist:jellyfin-signature:{CacheKeyBuilder.BuildSpotifyPlaylistScope(spotifyPlaylistName, playlistScopeUserId, playlistScopeId)}";
var currentJellyfinSignature = await GetJellyfinPlaylistSignatureAsync(playlistId);
var cachedJellyfinSignature = await _cache.GetAsync<string>(jellyfinSignatureCacheKey);
@@ -66,7 +73,10 @@ public partial class JellyfinController
var requestNeedsGenreMetadata = RequestIncludesField("Genres");
// Check Redis cache first for fast serving (only if Jellyfin playlist hasn't changed)
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(spotifyPlaylistName);
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
spotifyPlaylistName,
playlistScopeUserId,
playlistScopeId);
var cachedItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(cacheKey);
if (cachedItems != null && cachedItems.Count > 0 &&
@@ -110,7 +120,7 @@ public partial class JellyfinController
}
// Check file cache as fallback
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName);
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName, playlistScopeUserId, playlistScopeId);
if (fileItems != null && fileItems.Count > 0 &&
InjectedPlaylistItemHelper.ContainsSyntheticLocalItems(fileItems))
{
@@ -147,7 +157,10 @@ public partial class JellyfinController
}
// Check for ordered matched tracks from SpotifyTrackMatchingService
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(spotifyPlaylistName);
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
spotifyPlaylistName,
playlistScopeUserId,
playlistScopeId);
var orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
if (orderedTracks == null || orderedTracks.Count == 0)
@@ -162,11 +175,10 @@ public partial class JellyfinController
// Get existing Jellyfin playlist items (RAW - don't convert!)
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
var userId = _settings.UserId;
if (string.IsNullOrEmpty(userId))
{
_logger.LogError(
"❌ JELLYFIN_USER_ID is NOT configured! Cannot fetch playlist tracks. Set it in .env or admin UI.");
"❌ Could not resolve Jellyfin user from the current request. Cannot fetch playlist tracks.");
return null; // Fall back to legacy mode
}
@@ -237,7 +249,7 @@ public partial class JellyfinController
}
// Get the full playlist from Spotify to know the correct order
var spotifyTracks = await _spotifyPlaylistFetcher!.GetPlaylistTracksAsync(spotifyPlaylistName);
var spotifyTracks = await _spotifyPlaylistFetcher!.GetPlaylistTracksAsync(spotifyPlaylistName, userId, playlistId);
if (spotifyTracks.Count == 0)
{
_logger.LogWarning("Could not get Spotify playlist tracks for {Playlist}", spotifyPlaylistName);
@@ -394,7 +406,7 @@ public partial class JellyfinController
}
// Save to file cache for persistence across restarts
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems);
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems, playlistScopeUserId, playlistScopeId);
// Also cache in Redis for fast serving (reuse the same cache key from top of method)
await _cache.SetAsync(cacheKey, finalItems, CacheExtensions.SpotifyPlaylistItemsTTL);
@@ -916,7 +928,7 @@ public partial class JellyfinController
{
try
{
var userId = _settings.UserId;
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
var playlistItemsUrl = $"Playlists/{playlistId}/Items?Fields=Id";
if (!string.IsNullOrEmpty(userId))
{
@@ -958,14 +970,19 @@ public partial class JellyfinController
/// <summary>
/// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts.
/// </summary>
private async Task SavePlaylistItemsToFile(string playlistName, List<Dictionary<string, object?>> items)
private async Task SavePlaylistItemsToFile(
string playlistName,
List<Dictionary<string, object?>> items,
string? userId = null,
string? scopeId = null)
{
try
{
var cacheDir = "/app/cache/spotify";
Directory.CreateDirectory(cacheDir);
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var safeName = AdminHelperService.SanitizeFileName(
CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, userId, scopeId));
var filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
@@ -983,11 +1000,15 @@ public partial class JellyfinController
/// <summary>
/// Loads playlist items (raw Jellyfin JSON) from file cache.
/// </summary>
private async Task<List<Dictionary<string, object?>>?> LoadPlaylistItemsFromFile(string playlistName)
private async Task<List<Dictionary<string, object?>>?> LoadPlaylistItemsFromFile(
string playlistName,
string? userId = null,
string? scopeId = null)
{
try
{
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var safeName = AdminHelperService.SanitizeFileName(
CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, userId, scopeId));
var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_items.json");
if (!System.IO.File.Exists(filePath))
+7 -1
View File
@@ -40,6 +40,7 @@ public partial class JellyfinController : ControllerBase
private readonly JellyfinResponseBuilder _responseBuilder;
private readonly JellyfinModelMapper _modelMapper;
private readonly JellyfinProxyService _proxyService;
private readonly JellyfinUserContextResolver _jellyfinUserContextResolver;
private readonly JellyfinSessionManager _sessionManager;
private readonly PlaylistSyncService? _playlistSyncService;
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
@@ -65,6 +66,7 @@ public partial class JellyfinController : ControllerBase
JellyfinResponseBuilder responseBuilder,
JellyfinModelMapper modelMapper,
JellyfinProxyService proxyService,
JellyfinUserContextResolver jellyfinUserContextResolver,
JellyfinSessionManager sessionManager,
OdesliService odesliService,
RedisCacheService cache,
@@ -91,6 +93,7 @@ public partial class JellyfinController : ControllerBase
_responseBuilder = responseBuilder;
_modelMapper = modelMapper;
_proxyService = proxyService;
_jellyfinUserContextResolver = jellyfinUserContextResolver;
_sessionManager = sessionManager;
_playlistSyncService = playlistSyncService;
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
@@ -1735,7 +1738,10 @@ public partial class JellyfinController : ControllerBase
// Search through each playlist's matched tracks cache
foreach (var playlist in playlists)
{
var cacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name);
var cacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
playlist.Name,
playlist.UserId,
string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId);
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(cacheKey);
if (matchedTracks == null || matchedTracks.Count == 0)
+123 -27
View File
@@ -8,6 +8,7 @@ using allstarr.Services.Common;
using allstarr.Services.Admin;
using allstarr.Services;
using allstarr.Filters;
using allstarr.Services.Jellyfin;
using System.Text.Json;
namespace allstarr.Controllers;
@@ -27,6 +28,7 @@ public class PlaylistController : ControllerBase
private readonly HttpClient _jellyfinHttpClient;
private readonly AdminHelperService _helperService;
private readonly IServiceProvider _serviceProvider;
private readonly JellyfinUserContextResolver _jellyfinUserContextResolver;
private const string CacheDirectory = "/app/cache/spotify";
public PlaylistController(
@@ -39,6 +41,7 @@ public class PlaylistController : ControllerBase
IHttpClientFactory httpClientFactory,
AdminHelperService helperService,
IServiceProvider serviceProvider,
JellyfinUserContextResolver jellyfinUserContextResolver,
SpotifyTrackMatchingService? matchingService = null)
{
_logger = logger;
@@ -51,6 +54,23 @@ public class PlaylistController : ControllerBase
_jellyfinHttpClient = httpClientFactory.CreateClient();
_helperService = helperService;
_serviceProvider = serviceProvider;
_jellyfinUserContextResolver = jellyfinUserContextResolver;
}
private async Task<SpotifyPlaylistConfig?> ResolvePlaylistConfigForCurrentScopeAsync(string playlistName)
{
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync();
return _spotifyImportSettings.GetPlaylistByName(playlistName, userId);
}
private static string? GetPlaylistScopeId(SpotifyPlaylistConfig? playlist)
{
if (!string.IsNullOrWhiteSpace(playlist?.JellyfinId))
{
return playlist.JellyfinId;
}
return string.IsNullOrWhiteSpace(playlist?.Id) ? null : playlist.Id;
}
[HttpGet("playlists")]
@@ -149,7 +169,7 @@ public class PlaylistController : ControllerBase
{
try
{
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
spotifyTrackCount = spotifyTracks.Count;
playlistInfo["trackCount"] = spotifyTrackCount;
_logger.LogDebug("Fetched {Count} tracks from Spotify for playlist {Name}", spotifyTrackCount, config.Name);
@@ -167,7 +187,10 @@ public class PlaylistController : ControllerBase
try
{
// Try to use the pre-built playlist cache
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(config.Name);
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
config.Name,
config.UserId,
GetPlaylistScopeId(config));
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
try
@@ -239,7 +262,7 @@ public class PlaylistController : ControllerBase
else
{
// No playlist cache - calculate from global mappings as fallback
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
var localCount = 0;
var externalCount = 0;
var missingCount = 0;
@@ -291,7 +314,7 @@ public class PlaylistController : ControllerBase
try
{
// Jellyfin requires UserId parameter to fetch playlist items
var userId = _jellyfinSettings.UserId;
var userId = config.UserId;
// If no user configured, try to get the first user
if (string.IsNullOrEmpty(userId))
@@ -330,10 +353,13 @@ public class PlaylistController : ControllerBase
if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items))
{
// Get Spotify tracks to match against
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
// Try to use the pre-built playlist cache first (includes manual mappings!)
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(config.Name);
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
config.Name,
config.UserId,
GetPlaylistScopeId(config));
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
try
@@ -438,7 +464,10 @@ public class PlaylistController : ControllerBase
}
// Get matched external tracks cache once
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(config.Name);
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
config.Name,
config.UserId,
GetPlaylistScopeId(config));
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
var matchedSpotifyIds = new HashSet<string>(
matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
@@ -455,7 +484,11 @@ public class PlaylistController : ControllerBase
var hasExternalMapping = false;
// FIRST: Check for manual Jellyfin mapping
var manualMappingKey = $"spotify:manual-map:{config.Name}:{track.SpotifyId}";
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
config.Name,
track.SpotifyId,
config.UserId,
GetPlaylistScopeId(config));
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId))
@@ -466,7 +499,11 @@ public class PlaylistController : ControllerBase
else
{
// Check for external manual mapping
var externalMappingKey = $"spotify:external-map:{config.Name}:{track.SpotifyId}";
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
config.Name,
track.SpotifyId,
config.UserId,
GetPlaylistScopeId(config));
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
if (!string.IsNullOrEmpty(externalMappingJson))
@@ -592,16 +629,22 @@ public class PlaylistController : ControllerBase
public async Task<IActionResult> GetPlaylistTracks(string name)
{
var decodedName = Uri.UnescapeDataString(name);
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
var playlistScopeUserId = playlistConfig?.UserId;
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
// Get Spotify tracks
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName);
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName, playlistScopeUserId, playlistConfig?.JellyfinId);
var tracksWithStatus = new List<object>();
var matchedTracksBySpotifyId = new Dictionary<string, MatchedTrack>(StringComparer.OrdinalIgnoreCase);
try
{
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName);
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
if (matchedTracks != null)
@@ -627,7 +670,10 @@ public class PlaylistController : ControllerBase
// Use the pre-built playlist cache (same as GetPlaylists endpoint)
// This cache includes all matched tracks with proper provider IDs
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName);
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
try
@@ -948,7 +994,11 @@ public class PlaylistController : ControllerBase
string? externalProvider = null;
// Check for manual Jellyfin mapping
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
decodedName,
track.SpotifyId,
playlistScopeUserId,
playlistScopeId);
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId))
@@ -958,7 +1008,11 @@ public class PlaylistController : ControllerBase
else
{
// Check for external manual mapping
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
decodedName,
track.SpotifyId,
playlistScopeUserId,
playlistScopeId);
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
if (!string.IsNullOrEmpty(externalMappingJson))
@@ -1071,10 +1125,16 @@ public class PlaylistController : ControllerBase
try
{
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
var playlistScopeUserId = playlistConfig?.UserId;
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
await _playlistFetcher.RefreshPlaylistAsync(decodedName);
// Clear playlist stats cache first (so it gets recalculated with fresh data)
var statsCacheKey = $"spotify:playlist:stats:{decodedName}";
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
await _cache.DeleteAsync(statsCacheKey);
// Then invalidate playlist summary cache (will rebuild with fresh stats)
@@ -1109,18 +1169,28 @@ public class PlaylistController : ControllerBase
try
{
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
var playlistScopeUserId = playlistConfig?.UserId;
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
// Clear the Jellyfin playlist signature cache to force re-checking if local tracks changed
var jellyfinSignatureCacheKey = $"spotify:playlist:jellyfin-signature:{decodedName}";
var jellyfinSignatureCacheKey =
$"spotify:playlist:jellyfin-signature:{CacheKeyBuilder.BuildSpotifyPlaylistScope(decodedName, playlistScopeUserId, playlistScopeId)}";
await _cache.DeleteAsync(jellyfinSignatureCacheKey);
_logger.LogDebug("Cleared Jellyfin signature cache to force change detection");
// Clear the matched results cache to force re-matching
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName);
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
await _cache.DeleteAsync(matchedTracksKey);
_logger.LogDebug("Cleared matched tracks cache");
// Clear the playlist items cache
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName);
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
await _cache.DeleteAsync(playlistItemsCacheKey);
_logger.LogDebug("Cleared playlist items cache");
@@ -1131,7 +1201,10 @@ public class PlaylistController : ControllerBase
_helperService.InvalidatePlaylistSummaryCache();
// Clear playlist stats cache to force recalculation from new mappings
var statsCacheKey = $"spotify:playlist:stats:{decodedName}";
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
await _cache.DeleteAsync(statsCacheKey);
_logger.LogDebug("Cleared stats cache for {Name}", decodedName);
@@ -1196,7 +1269,7 @@ public class PlaylistController : ControllerBase
try
{
var userId = _jellyfinSettings.UserId;
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync();
// Build URL with UserId if available
var url = $"{_jellyfinSettings.Url}/Items?searchTerm={Uri.EscapeDataString(query)}&includeItemTypes=Audio&recursive=true&limit=20";
@@ -1328,7 +1401,7 @@ public class PlaylistController : ControllerBase
try
{
var userId = _jellyfinSettings.UserId;
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync();
var url = $"{_jellyfinSettings.Url}/Items/{id}";
if (!string.IsNullOrEmpty(userId))
@@ -1424,13 +1497,20 @@ public class PlaylistController : ControllerBase
try
{
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
var playlistScopeUserId = playlistConfig?.UserId;
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
string? normalizedProvider = null;
string? normalizedExternalId = null;
if (hasJellyfinMapping)
{
// Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent)
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
var mappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
decodedName,
request.SpotifyId,
playlistScopeUserId,
playlistScopeId);
await _cache.SetAsync(mappingKey, request.JellyfinId!);
// Also save to file for persistence across restarts
@@ -1442,7 +1522,11 @@ public class PlaylistController : ControllerBase
else
{
// Store external mapping in cache (NO EXPIRATION - manual mappings are permanent)
var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
decodedName,
request.SpotifyId,
playlistScopeUserId,
playlistScopeId);
normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
normalizedExternalId = NormalizeExternalTrackId(normalizedProvider, request.ExternalId!);
var externalMapping = new { provider = normalizedProvider, id = normalizedExternalId };
@@ -1482,10 +1566,22 @@ public class PlaylistController : ControllerBase
}
// Clear all related caches to force rebuild
var matchedCacheKey = $"spotify:matched:{decodedName}";
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName);
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName);
var statsCacheKey = $"spotify:playlist:stats:{decodedName}";
var matchedCacheKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
await _cache.DeleteAsync(matchedCacheKey);
await _cache.DeleteAsync(orderedCacheKey);
@@ -357,9 +357,9 @@ public class SpotifyAdminController : ControllerBase
{
var keys = new[]
{
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name)
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId)
};
foreach (var key in keys)
@@ -126,8 +126,33 @@ public class SpotifyImportSettings
/// <summary>
/// Gets the playlist configuration by name.
/// </summary>
public SpotifyPlaylistConfig? GetPlaylistByName(string name) =>
Playlists.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
public SpotifyPlaylistConfig? GetPlaylistByName(string name, string? userId = null, string? jellyfinPlaylistId = null)
{
var matches = Playlists
.Where(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
.ToList();
if (!string.IsNullOrWhiteSpace(jellyfinPlaylistId))
{
var byPlaylistId = matches.FirstOrDefault(p =>
p.JellyfinId.Equals(jellyfinPlaylistId, StringComparison.OrdinalIgnoreCase));
if (byPlaylistId != null)
{
return byPlaylistId;
}
}
if (!string.IsNullOrWhiteSpace(userId))
{
var normalizedUserId = userId.Trim();
return matches.FirstOrDefault(p =>
!string.IsNullOrWhiteSpace(p.UserId) &&
p.UserId.Equals(normalizedUserId, StringComparison.OrdinalIgnoreCase))
?? matches.FirstOrDefault(p => string.IsNullOrWhiteSpace(p.UserId));
}
return matches.FirstOrDefault();
}
/// <summary>
/// Checks if a Jellyfin playlist ID is configured for Spotify import.
+1
View File
@@ -541,6 +541,7 @@ if (backendType == BackendType.Jellyfin)
// Jellyfin services
builder.Services.AddSingleton<JellyfinResponseBuilder>();
builder.Services.AddSingleton<JellyfinModelMapper>();
builder.Services.AddScoped<JellyfinUserContextResolver>();
builder.Services.AddScoped<JellyfinProxyService>();
builder.Services.AddSingleton<JellyfinSessionManager>();
builder.Services.AddScoped<JellyfinAuthFilter>();
+93 -19
View File
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
using System.Text.RegularExpressions;
namespace allstarr.Services.Common;
@@ -9,6 +10,10 @@ namespace allstarr.Services.Common;
/// </summary>
public static class AuthHeaderHelper
{
private static readonly Regex AuthParameterRegex = new(
@"(?<key>[A-Za-z0-9_-]+)\s*=\s*""(?<value>[^""]*)""",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
/// <summary>
/// Forwards authentication headers from HTTP request to HttpRequestMessage.
/// Handles both X-Emby-Authorization and Authorization headers.
@@ -99,17 +104,7 @@ public static class AuthHeaderHelper
/// </summary>
private static string? ExtractDeviceIdFromAuthString(string authValue)
{
var deviceIdMatch = System.Text.RegularExpressions.Regex.Match(
authValue,
@"DeviceId=""([^""]+)""",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (deviceIdMatch.Success)
{
return deviceIdMatch.Groups[1].Value;
}
return null;
return ExtractAuthParameter(authValue, "DeviceId");
}
/// <summary>
@@ -140,16 +135,95 @@ public static class AuthHeaderHelper
/// </summary>
private static string? ExtractClientNameFromAuthString(string authValue)
{
var clientMatch = System.Text.RegularExpressions.Regex.Match(
authValue,
@"Client=""([^""]+)""",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (clientMatch.Success)
return ExtractAuthParameter(authValue, "Client");
}
/// <summary>
/// Extracts the authenticated Jellyfin access token from request headers.
/// Supports X-Emby-Authorization, X-Emby-Token, Authorization: MediaBrowser ..., and Bearer tokens.
/// </summary>
public static string? ExtractAccessToken(IHeaderDictionary headers)
{
if (headers.TryGetValue("X-Emby-Token", out var tokenHeader))
{
return clientMatch.Groups[1].Value;
var token = tokenHeader.ToString().Trim();
if (!string.IsNullOrWhiteSpace(token))
{
return token;
}
}
if (headers.TryGetValue("X-Emby-Authorization", out var authHeader))
{
var token = ExtractAuthParameter(authHeader.ToString(), "Token");
if (!string.IsNullOrWhiteSpace(token))
{
return token;
}
}
if (headers.TryGetValue("Authorization", out var authorizationHeader))
{
var authValue = authorizationHeader.ToString().Trim();
if (authValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
var bearerToken = authValue["Bearer ".Length..].Trim();
return string.IsNullOrWhiteSpace(bearerToken) ? null : bearerToken;
}
var token = ExtractAuthParameter(authValue, "Token");
if (!string.IsNullOrWhiteSpace(token))
{
return token;
}
}
return null;
}
/// <summary>
/// Extracts a Jellyfin user id from auth headers when present.
/// This is uncommon but some clients may include it in MediaBrowser auth parameters.
/// </summary>
public static string? ExtractUserId(IHeaderDictionary headers)
{
if (headers.TryGetValue("X-Emby-Authorization", out var authHeader))
{
var userId = ExtractAuthParameter(authHeader.ToString(), "UserId");
if (!string.IsNullOrWhiteSpace(userId))
{
return userId;
}
}
if (headers.TryGetValue("Authorization", out var authorizationHeader))
{
var userId = ExtractAuthParameter(authorizationHeader.ToString(), "UserId");
if (!string.IsNullOrWhiteSpace(userId))
{
return userId;
}
}
return null;
}
private static string? ExtractAuthParameter(string authValue, string parameterName)
{
if (string.IsNullOrWhiteSpace(authValue))
{
return null;
}
foreach (Match match in AuthParameterRegex.Matches(authValue))
{
if (match.Groups["key"].Value.Equals(parameterName, StringComparison.OrdinalIgnoreCase))
{
var value = match.Groups["value"].Value;
return string.IsNullOrWhiteSpace(value) ? null : value;
}
}
return null;
}
+44 -18
View File
@@ -67,34 +67,52 @@ public static class CacheKeyBuilder
#region Spotify Keys
public static string BuildSpotifyPlaylistKey(string playlistName)
public static string BuildSpotifyPlaylistScope(string playlistName, string? userId = null, string? scopeId = null)
{
return $"spotify:playlist:{playlistName}";
var normalizedUserId = Normalize(userId);
var normalizedScopeId = Normalize(scopeId);
var normalizedPlaylistName = Normalize(playlistName);
if (string.IsNullOrEmpty(normalizedUserId) && string.IsNullOrEmpty(normalizedScopeId))
{
return playlistName;
}
var effectiveScopeId = string.IsNullOrEmpty(normalizedScopeId)
? normalizedPlaylistName
: normalizedScopeId;
return $"{normalizedUserId}:{effectiveScopeId}";
}
public static string BuildSpotifyPlaylistItemsKey(string playlistName)
public static string BuildSpotifyPlaylistKey(string playlistName, string? userId = null, string? scopeId = null)
{
return $"spotify:playlist:items:{playlistName}";
return $"spotify:playlist:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
}
public static string BuildSpotifyPlaylistOrderedKey(string playlistName)
public static string BuildSpotifyPlaylistItemsKey(string playlistName, string? userId = null, string? scopeId = null)
{
return $"spotify:playlist:ordered:{playlistName}";
return $"spotify:playlist:items:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
}
public static string BuildSpotifyMatchedTracksKey(string playlistName)
public static string BuildSpotifyPlaylistOrderedKey(string playlistName, string? userId = null, string? scopeId = null)
{
return $"spotify:matched:ordered:{playlistName}";
return $"spotify:playlist:ordered:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
}
public static string BuildSpotifyLegacyMatchedTracksKey(string playlistName)
public static string BuildSpotifyMatchedTracksKey(string playlistName, string? userId = null, string? scopeId = null)
{
return $"spotify:matched:{playlistName}";
return $"spotify:matched:ordered:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
}
public static string BuildSpotifyPlaylistStatsKey(string playlistName)
public static string BuildSpotifyLegacyMatchedTracksKey(string playlistName, string? userId = null, string? scopeId = null)
{
return $"spotify:playlist:stats:{playlistName}";
return $"spotify:matched:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
}
public static string BuildSpotifyPlaylistStatsKey(string playlistName, string? userId = null, string? scopeId = null)
{
return $"spotify:playlist:stats:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
}
public static string BuildSpotifyPlaylistStatsPattern()
@@ -102,19 +120,27 @@ public static class CacheKeyBuilder
return "spotify:playlist:stats:*";
}
public static string BuildSpotifyMissingTracksKey(string playlistName)
public static string BuildSpotifyMissingTracksKey(string playlistName, string? userId = null, string? scopeId = null)
{
return $"spotify:missing:{playlistName}";
return $"spotify:missing:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
}
public static string BuildSpotifyManualMappingKey(string playlist, string spotifyId)
public static string BuildSpotifyManualMappingKey(
string playlist,
string spotifyId,
string? userId = null,
string? scopeId = null)
{
return $"spotify:manual-map:{playlist}:{spotifyId}";
return $"spotify:manual-map:{BuildSpotifyPlaylistScope(playlist, userId, scopeId)}:{spotifyId}";
}
public static string BuildSpotifyExternalMappingKey(string playlist, string spotifyId)
public static string BuildSpotifyExternalMappingKey(
string playlist,
string spotifyId,
string? userId = null,
string? scopeId = null)
{
return $"spotify:external-map:{playlist}:{spotifyId}";
return $"spotify:external-map:{BuildSpotifyPlaylistScope(playlist, userId, scopeId)}:{spotifyId}";
}
public static string BuildSpotifyGlobalMappingKey(string spotifyId)
@@ -24,6 +24,7 @@ public class JellyfinProxyService
private readonly HttpClient _httpClient;
private readonly JellyfinSettings _settings;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly JellyfinUserContextResolver _userContextResolver;
private readonly ILogger<JellyfinProxyService> _logger;
private readonly RedisCacheService _cache;
private string? _cachedMusicLibraryId;
@@ -36,16 +37,35 @@ public class JellyfinProxyService
IHttpClientFactory httpClientFactory,
IOptions<JellyfinSettings> settings,
IHttpContextAccessor httpContextAccessor,
JellyfinUserContextResolver userContextResolver,
ILogger<JellyfinProxyService> logger,
RedisCacheService cache)
{
_httpClient = httpClientFactory.CreateClient(HttpClientName);
_settings = settings.Value;
_httpContextAccessor = httpContextAccessor;
_userContextResolver = userContextResolver;
_logger = logger;
_cache = cache;
}
private async Task AddResolvedUserIdAsync(
Dictionary<string, string> queryParams,
IHeaderDictionary? clientHeaders = null,
bool allowConfigurationFallback = true)
{
if (queryParams.ContainsKey("userId") || queryParams.ContainsKey("UserId"))
{
return;
}
var userId = await _userContextResolver.ResolveCurrentUserIdAsync(clientHeaders, allowConfigurationFallback);
if (!string.IsNullOrWhiteSpace(userId))
{
queryParams["userId"] = userId;
}
}
/// <summary>
/// Gets the music library ID, auto-detecting it if not configured.
/// </summary>
@@ -552,10 +572,7 @@ public class JellyfinProxyService
["fields"] = "PrimaryImageAspectRatio,MediaSources,Path,Genres,Studios,DateCreated,Overview,ProviderIds"
};
if (!string.IsNullOrEmpty(_settings.UserId))
{
queryParams["userId"] = _settings.UserId;
}
await AddResolvedUserIdAsync(queryParams, clientHeaders);
// Note: We don't force parentId here - let clients specify which library to search
// The controller will detect music library searches and add external results
@@ -602,10 +619,7 @@ public class JellyfinProxyService
["fields"] = "PrimaryImageAspectRatio,MediaSources,Path,Genres,Studios,DateCreated,Overview,ProviderIds,ParentId"
};
if (!string.IsNullOrEmpty(_settings.UserId))
{
queryParams["userId"] = _settings.UserId;
}
await AddResolvedUserIdAsync(queryParams, clientHeaders);
if (!string.IsNullOrEmpty(parentId))
{
@@ -647,10 +661,7 @@ public class JellyfinProxyService
{
var queryParams = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(_settings.UserId))
{
queryParams["userId"] = _settings.UserId;
}
await AddResolvedUserIdAsync(queryParams, clientHeaders);
return await GetJsonAsync($"Items/{itemId}", queryParams, clientHeaders);
}
@@ -669,10 +680,7 @@ public class JellyfinProxyService
["fields"] = "PrimaryImageAspectRatio,Genres,Overview"
};
if (!string.IsNullOrEmpty(_settings.UserId))
{
queryParams["userId"] = _settings.UserId;
}
await AddResolvedUserIdAsync(queryParams, clientHeaders);
if (!string.IsNullOrEmpty(searchTerm))
{
@@ -699,10 +707,7 @@ public class JellyfinProxyService
{
var queryParams = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(_settings.UserId))
{
queryParams["userId"] = _settings.UserId;
}
await AddResolvedUserIdAsync(queryParams, clientHeaders);
// Try to get by ID first
if (Guid.TryParse(artistIdOrName, out _))
@@ -893,10 +898,7 @@ public class JellyfinProxyService
try
{
var queryParams = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(_settings.UserId))
{
queryParams["userId"] = _settings.UserId;
}
await AddResolvedUserIdAsync(queryParams);
var (result, statusCode) = await GetJsonAsync("Library/MediaFolders", queryParams);
if (result == null)
@@ -0,0 +1,155 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using allstarr.Models.Settings;
using allstarr.Services.Admin;
using allstarr.Services.Common;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
namespace allstarr.Services.Jellyfin;
/// <summary>
/// Resolves the effective Jellyfin user for the current request.
/// Prefers explicit request/session context and falls back to the legacy configured user id.
/// </summary>
public class JellyfinUserContextResolver
{
private static readonly TimeSpan TokenLookupCacheTtl = TimeSpan.FromMinutes(5);
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IHttpClientFactory _httpClientFactory;
private readonly JellyfinSettings _settings;
private readonly IMemoryCache _memoryCache;
private readonly ILogger<JellyfinUserContextResolver> _logger;
public JellyfinUserContextResolver(
IHttpContextAccessor httpContextAccessor,
IHttpClientFactory httpClientFactory,
IOptions<JellyfinSettings> settings,
IMemoryCache memoryCache,
ILogger<JellyfinUserContextResolver> logger)
{
_httpContextAccessor = httpContextAccessor;
_httpClientFactory = httpClientFactory;
_settings = settings.Value;
_memoryCache = memoryCache;
_logger = logger;
}
public async Task<string?> ResolveCurrentUserIdAsync(
IHeaderDictionary? headers = null,
bool allowConfigurationFallback = true,
CancellationToken cancellationToken = default)
{
var httpContext = _httpContextAccessor.HttpContext;
var request = httpContext?.Request;
headers ??= request?.Headers;
var explicitUserId = request?.RouteValues["userId"]?.ToString();
if (string.IsNullOrWhiteSpace(explicitUserId))
{
explicitUserId = request?.Query["userId"].ToString();
}
if (!string.IsNullOrWhiteSpace(explicitUserId))
{
return explicitUserId.Trim();
}
if (httpContext?.Items.TryGetValue(AdminAuthSessionService.HttpContextSessionItemKey, out var sessionObj) == true &&
sessionObj is AdminAuthSession session &&
!string.IsNullOrWhiteSpace(session.UserId))
{
return session.UserId.Trim();
}
if (headers != null)
{
var headerUserId = AuthHeaderHelper.ExtractUserId(headers);
if (!string.IsNullOrWhiteSpace(headerUserId))
{
return headerUserId.Trim();
}
var token = AuthHeaderHelper.ExtractAccessToken(headers);
if (!string.IsNullOrWhiteSpace(token))
{
var cacheKey = BuildTokenCacheKey(token);
if (_memoryCache.TryGetValue(cacheKey, out string? cachedUserId) &&
!string.IsNullOrWhiteSpace(cachedUserId))
{
return cachedUserId;
}
var resolvedUserId = await ResolveUserIdFromJellyfinAsync(headers, cancellationToken);
if (!string.IsNullOrWhiteSpace(resolvedUserId))
{
_memoryCache.Set(cacheKey, resolvedUserId.Trim(), TokenLookupCacheTtl);
return resolvedUserId.Trim();
}
}
}
if (allowConfigurationFallback && !string.IsNullOrWhiteSpace(_settings.UserId))
{
_logger.LogDebug("Falling back to configured Jellyfin user id for current request scope");
return _settings.UserId.Trim();
}
return null;
}
private async Task<string?> ResolveUserIdFromJellyfinAsync(
IHeaderDictionary headers,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_settings.Url))
{
return null;
}
try
{
using var request = new HttpRequestMessage(
HttpMethod.Get,
$"{_settings.Url.TrimEnd('/')}/Users/Me");
if (!AuthHeaderHelper.ForwardAuthHeaders(headers, request))
{
return null;
}
request.Headers.Accept.ParseAdd("application/json");
var client = _httpClientFactory.CreateClient(JellyfinProxyService.HttpClientName);
using var response = await client.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger.LogDebug("Failed to resolve Jellyfin user from token via /Users/Me: {StatusCode}",
response.StatusCode);
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
if (doc.RootElement.TryGetProperty("Id", out var idProp))
{
var userId = idProp.GetString();
return string.IsNullOrWhiteSpace(userId) ? null : userId.Trim();
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error resolving Jellyfin user from auth token");
}
return null;
}
private static string BuildTokenCacheKey(string token)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(token));
return $"jellyfin:user-from-token:{Convert.ToHexString(hash)}";
}
}
@@ -29,7 +29,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
private readonly RedisCacheService _cache;
// Track Spotify playlist IDs after discovery
private readonly Dictionary<string, string> _playlistNameToSpotifyId = new();
private readonly Dictionary<string, string> _playlistScopeToSpotifyId = new();
public SpotifyPlaylistFetcher(
ILogger<SpotifyPlaylistFetcher> logger,
@@ -55,10 +55,18 @@ public class SpotifyPlaylistFetcher : BackgroundService
/// </summary>
/// <param name="playlistName">Playlist name (e.g., "Release Radar", "Discover Weekly")</param>
/// <returns>List of tracks in playlist order, or empty list if not found</returns>
public async Task<List<SpotifyPlaylistTrack>> GetPlaylistTracksAsync(string playlistName)
public async Task<List<SpotifyPlaylistTrack>> GetPlaylistTracksAsync(
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null)
{
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName);
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
var playlistConfig = !string.IsNullOrWhiteSpace(jellyfinPlaylistId)
? _spotifyImportSettings.GetPlaylistByJellyfinId(jellyfinPlaylistId)
: _spotifyImportSettings.GetPlaylistByName(playlistName, userId);
var playlistScopeUserId = playlistConfig?.UserId ?? userId;
var playlistScopeId = playlistConfig?.JellyfinId ?? playlistConfig?.Id ?? jellyfinPlaylistId;
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName, playlistScopeUserId, playlistScopeId);
var playlistScope = CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, playlistScopeUserId, playlistScopeId);
// Try Redis cache first
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
@@ -124,14 +132,14 @@ public class SpotifyPlaylistFetcher : BackgroundService
try
{
// Try to use cached or configured Spotify playlist ID
if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId))
if (!_playlistScopeToSpotifyId.TryGetValue(playlistScope, out var spotifyId))
{
// Check if we have a configured Spotify ID for this playlist
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.Id))
{
// Use the configured Spotify playlist ID directly
spotifyId = playlistConfig.Id;
_playlistNameToSpotifyId[playlistName] = spotifyId;
_playlistScopeToSpotifyId[playlistScope] = spotifyId;
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
}
else
@@ -150,7 +158,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
}
spotifyId = exactMatch.SpotifyId;
_playlistNameToSpotifyId[playlistName] = spotifyId;
_playlistScopeToSpotifyId[playlistScope] = spotifyId;
_logger.LogInformation("Found Spotify playlist '{Name}' with ID: {Id}", playlistName, spotifyId);
}
}
@@ -226,7 +234,8 @@ public class SpotifyPlaylistFetcher : BackgroundService
string playlistName,
HashSet<string> jellyfinTrackIds)
{
var allTracks = await GetPlaylistTracksAsync(playlistName);
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
var allTracks = await GetPlaylistTracksAsync(playlistName, playlistConfig?.UserId, playlistConfig?.JellyfinId);
// Filter to only tracks not in Jellyfin, preserving order
return allTracks
@@ -242,11 +251,15 @@ public class SpotifyPlaylistFetcher : BackgroundService
_logger.LogInformation("Manual refresh triggered for playlist '{Name}'", playlistName);
// Clear cache to force refresh
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName);
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(
playlistName,
playlistConfig?.UserId,
playlistConfig?.JellyfinId ?? playlistConfig?.Id);
await _cache.DeleteAsync(cacheKey);
// Re-fetch
await GetPlaylistTracksAsync(playlistName);
await GetPlaylistTracksAsync(playlistName, playlistConfig?.UserId, playlistConfig?.JellyfinId);
await ClearPlaylistImageCacheAsync(playlistName);
}
@@ -342,7 +355,10 @@ public class SpotifyPlaylistFetcher : BackgroundService
var cron = CronExpression.Parse(schedule);
// Check if we have cached data
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(config.Name);
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(
config.Name,
config.UserId,
config.JellyfinId ?? config.Id);
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
if (cached != null)
@@ -380,7 +396,8 @@ public class SpotifyPlaylistFetcher : BackgroundService
try
{
await GetPlaylistTracksAsync(playlistName);
var config = _spotifyImportSettings.GetPlaylistByName(playlistName);
await GetPlaylistTracksAsync(playlistName, config?.UserId, config?.JellyfinId);
// Rate limiting between playlists
if (playlistName != needsRefresh.Last())
@@ -419,7 +436,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
try
{
var tracks = await GetPlaylistTracksAsync(config.Name);
var tracks = await GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
_logger.LogDebug(" {Name}: {Count} tracks", config.Name, tracks.Count);
// Log sample of track order for debugging
@@ -1,6 +1,7 @@
using allstarr.Models.Domain;
using allstarr.Models.Settings;
using allstarr.Models.Spotify;
using allstarr.Services.Admin;
using allstarr.Services.Common;
using allstarr.Services.Jellyfin;
using Microsoft.AspNetCore.Http;
@@ -72,6 +73,19 @@ public class SpotifyTrackMatchingService : BackgroundService
return true;
}
private static string? GetPlaylistScopeUserId(SpotifyPlaylistConfig? playlist) =>
string.IsNullOrWhiteSpace(playlist?.UserId) ? null : playlist.UserId.Trim();
private static string? GetPlaylistScopeId(SpotifyPlaylistConfig? playlist)
{
if (!string.IsNullOrWhiteSpace(playlist?.JellyfinId))
{
return playlist.JellyfinId.Trim();
}
return string.IsNullOrWhiteSpace(playlist?.Id) ? null : playlist.Id.Trim();
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("========================================");
@@ -236,18 +250,21 @@ public class SpotifyTrackMatchingService : BackgroundService
return;
}
var playlistScopeUserId = GetPlaylistScopeUserId(playlist);
var playlistScopeId = GetPlaylistScopeId(playlist);
_logger.LogInformation("Step 1/3: Clearing cache for {Playlist}", playlistName);
// Clear cache for this playlist (same as "Rebuild All Remote" button)
var keysToDelete = new[]
{
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlist.Name), // Legacy key
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyPlaylistOrderedKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(playlist.Name)
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name, playlistScopeUserId, playlistScopeId),
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name, playlistScopeUserId, playlistScopeId),
CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlist.Name, playlistScopeUserId, playlistScopeId),
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name, playlistScopeUserId, playlistScopeId),
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name, playlistScopeUserId, playlistScopeId),
CacheKeyBuilder.BuildSpotifyPlaylistOrderedKey(playlist.Name, playlistScopeUserId, playlistScopeId),
CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(playlist.Name, playlistScopeUserId, playlistScopeId)
};
foreach (var key in keysToDelete)
@@ -517,10 +534,20 @@ public class SpotifyTrackMatchingService : BackgroundService
IMusicMetadataService metadataService,
CancellationToken cancellationToken)
{
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName);
var playlistConfig = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
var playlistScopeUserId = GetPlaylistScopeUserId(playlistConfig);
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
// Get playlist tracks with full metadata including ISRC and position
var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(playlistName);
var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(
playlistName,
playlistScopeUserId,
playlistConfig?.JellyfinId);
if (spotifyTracks.Count == 0)
{
_logger.LogWarning("No tracks found for {Playlist}, skipping matching", playlistName);
@@ -528,8 +555,6 @@ public class SpotifyTrackMatchingService : BackgroundService
}
// Get the Jellyfin playlist ID to check which tracks already exist
var playlistConfig = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
HashSet<string> existingSpotifyIds = new();
@@ -545,8 +570,9 @@ public class SpotifyTrackMatchingService : BackgroundService
try
{
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
var userId = jellyfinSettings.UserId;
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
var userId = playlistConfig?.UserId ?? jellyfinSettings.UserId;
var jellyfinPlaylistId = playlistConfig!.JellyfinId;
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items";
var queryParams = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(userId))
{
@@ -628,10 +654,18 @@ public class SpotifyTrackMatchingService : BackgroundService
foreach (var track in tracksToMatch)
{
// Check if this track has a manual mapping but isn't in the cached results
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(playlistName, track.SpotifyId);
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
playlistName,
track.SpotifyId,
playlistScopeUserId,
playlistScopeId);
var manualMapping = await _cache.GetAsync<string>(manualMappingKey);
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(playlistName, track.SpotifyId);
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
playlistName,
track.SpotifyId,
playlistScopeUserId,
playlistScopeId);
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
var hasManualMapping = !string.IsNullOrEmpty(manualMapping) || !string.IsNullOrEmpty(externalMappingJson);
@@ -670,8 +704,9 @@ public class SpotifyTrackMatchingService : BackgroundService
{
try
{
var userId = jellyfinSettings.UserId;
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
var userId = playlistConfig?.UserId ?? jellyfinSettings.UserId;
var jellyfinPlaylistId = playlistConfig!.JellyfinId;
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items";
var queryParams = new Dictionary<string, string> { ["Fields"] = CachedPlaylistItemFields };
if (!string.IsNullOrEmpty(userId))
{
@@ -942,7 +977,10 @@ public class SpotifyTrackMatchingService : BackgroundService
["missing"] = statsMissingCount
};
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(playlistName);
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
await _cache.SetAsync(statsCacheKey, stats, TimeSpan.FromMinutes(30));
_logger.LogInformation("📊 Updated stats cache for {Playlist}: {Local} local, {External} external, {Missing} missing",
@@ -981,10 +1019,13 @@ public class SpotifyTrackMatchingService : BackgroundService
await _cache.SetAsync(matchedTracksKey, matchedTracks, cacheExpiration);
// Save matched tracks to file for persistence across restarts
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks, playlistScopeUserId, playlistScopeId);
// Also update legacy cache for backward compatibility
var legacyKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlistName);
var legacyKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
await _cache.SetAsync(legacyKey, legacySongs, cacheExpiration);
@@ -1269,8 +1310,18 @@ public class SpotifyTrackMatchingService : BackgroundService
IMusicMetadataService metadataService,
CancellationToken cancellationToken)
{
var missingTracksKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName);
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlistName);
var playlistConfig = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
var playlistScopeUserId = GetPlaylistScopeUserId(playlistConfig);
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
var missingTracksKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
// Check if we already have matched tracks cached
var existingMatched = await _cache.GetAsync<List<Song>>(matchedTracksKey);
@@ -1377,6 +1428,10 @@ public class SpotifyTrackMatchingService : BackgroundService
{
try
{
var playlistConfig = _spotifySettings.GetPlaylistByName(playlistName, jellyfinPlaylistId: jellyfinPlaylistId);
var playlistScopeUserId = GetPlaylistScopeUserId(playlistConfig);
var playlistScopeId = GetPlaylistScopeId(playlistConfig) ?? jellyfinPlaylistId;
_logger.LogDebug("🔨 Pre-building playlist items cache for {Playlist}...", playlistName);
if (string.IsNullOrEmpty(jellyfinPlaylistId))
@@ -1397,7 +1452,7 @@ public class SpotifyTrackMatchingService : BackgroundService
return;
}
var userId = jellyfinSettings.UserId;
var userId = playlistConfig?.UserId ?? jellyfinSettings.UserId;
if (string.IsNullOrEmpty(userId))
{
_logger.LogError("No UserId configured, cannot pre-build playlist cache for {Playlist}", playlistName);
@@ -1473,7 +1528,11 @@ public class SpotifyTrackMatchingService : BackgroundService
string? matchedKey = null;
// FIRST: Check for manual Jellyfin mapping
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(playlistName, spotifyTrack.SpotifyId);
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
playlistName,
spotifyTrack.SpotifyId,
playlistScopeUserId,
playlistScopeId);
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId))
@@ -1553,7 +1612,11 @@ public class SpotifyTrackMatchingService : BackgroundService
}
// SECOND: Check for external manual mapping
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(playlistName, spotifyTrack.SpotifyId);
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
playlistName,
spotifyTrack.SpotifyId,
playlistScopeUserId,
playlistScopeId);
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
if (!string.IsNullOrEmpty(externalMappingJson))
@@ -1841,11 +1904,14 @@ public class SpotifyTrackMatchingService : BackgroundService
}
// Save to Redis cache with same expiration as matched tracks (until next cron run)
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlistName);
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);
// Save to file cache for persistence
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
await SavePlaylistItemsToFileAsync(playlistName, finalItems, playlistScopeUserId, playlistScopeId);
var manualMappingInfo = "";
if (manualExternalCount > 0)
@@ -1870,14 +1936,19 @@ public class SpotifyTrackMatchingService : BackgroundService
/// <summary>
/// Saves playlist items to file cache for persistence across restarts.
/// </summary>
private async Task SavePlaylistItemsToFileAsync(string playlistName, List<Dictionary<string, object?>> items)
private async Task SavePlaylistItemsToFileAsync(
string playlistName,
List<Dictionary<string, object?>> items,
string? userId = null,
string? scopeId = null)
{
try
{
var cacheDir = "/app/cache/spotify";
Directory.CreateDirectory(cacheDir);
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var safeName = AdminHelperService.SanitizeFileName(
CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, userId, scopeId));
var filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
@@ -1894,14 +1965,19 @@ public class SpotifyTrackMatchingService : BackgroundService
/// <summary>
/// Saves matched tracks to file cache for persistence across restarts.
/// </summary>
private async Task SaveMatchedTracksToFileAsync(string playlistName, List<MatchedTrack> matchedTracks)
private async Task SaveMatchedTracksToFileAsync(
string playlistName,
List<MatchedTrack> matchedTracks,
string? userId = null,
string? scopeId = null)
{
try
{
var cacheDir = "/app/cache/spotify";
Directory.CreateDirectory(cacheDir);
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var safeName = AdminHelperService.SanitizeFileName(
CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, userId, scopeId));
var filePath = Path.Combine(cacheDir, $"{safeName}_matched.json");
var json = JsonSerializer.Serialize(matchedTracks, new JsonSerializerOptions { WriteIndented = true });