mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-27 03:53:10 -04:00
feat(jellyfin): add per-request multi-user support
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user