Compare commits

...

17 Commits

Author SHA1 Message Date
joshpatra d96f722fa1 fix squidwtf diagnostics and search bucket fanout 2026-04-06 11:09:17 -04:00
joshpatra e9099c45d5 add SquidWTF endpoint diagnostics 2026-04-06 10:55:18 -04:00
joshpatra e3adaae924 fix global playlist cache scope for injection 2026-04-06 10:37:43 -04:00
joshpatra 67db8b185f fix scoped injected playlist matching 2026-04-06 04:10:04 -04:00
joshpatra 579c1e04d8 fix search serialization and warm playlist matching
CI / build-and-test (push) Has been cancelled
2026-04-06 03:51:31 -04:00
joshpatra 885c86358d fix cache serialization fallback for Jellyfin metadata 2026-04-06 03:28:12 -04:00
joshpatra 7550d01667 fix(search): restore album ordering helpers after cherry-pick 2026-04-06 03:14:31 -04:00
joshpatra af54a3eec1 perf(cache): use ValueTask on hot sync paths 2026-04-06 03:13:08 -04:00
joshpatra 7beac7484d perf(images): support conditional ETag responses 2026-04-06 03:13:08 -04:00
joshpatra 997f60b0a8 perf(search): stream merged JSON responses 2026-04-06 03:13:08 -04:00
joshpatra 6965bdc46d perf(jellyfin): stream JSON proxy parsing 2026-04-06 03:10:35 -04:00
joshpatra ad6f521795 perf(json): finish source-generated hot-path serialization 2026-04-06 03:10:35 -04:00
joshpatra 81bae5621a fix(jellyfin): handle external contributing artist album requests as appears-on results 2026-04-06 03:10:35 -04:00
joshpatra dc225945f8 feat(jellyfin): add per-request multi-user support 2026-04-06 03:10:35 -04:00
joshpatra 8be544bdfc feat(cache): add IMemoryCache tier in front of Redis and cover invalidation paths 2026-04-06 03:10:35 -04:00
joshpatra e34c4bd125 perf: add System.Text.Json source generators for hot-path serialization 2026-04-06 03:10:34 -04:00
joshpatra b1808bd60c perf: use named HttpClient with SocketsHttpHandler connection pooling for Jellyfin backend 2026-04-06 03:10:34 -04:00
51 changed files with 3779 additions and 434 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));
}
}
@@ -122,7 +122,8 @@ public class ConfigControllerAuthorizationTests
Enabled = false,
ConnectionString = "localhost:6379"
}),
redisLogger.Object);
redisLogger.Object,
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
var spotifyCookieLogger = new Mock<ILogger<SpotifySessionCookieService>>();
var spotifySessionCookieService = new SpotifySessionCookieService(
Options.Create(new SpotifyApiSettings()),
@@ -0,0 +1,220 @@
using System.Net;
using System.Net.Http;
using System.Text;
using allstarr.Controllers;
using allstarr.Models.Admin;
using allstarr.Models.Settings;
using allstarr.Services.Admin;
using allstarr.Services.Common;
using allstarr.Services.Spotify;
using allstarr.Services.SquidWTF;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
namespace allstarr.Tests;
public class DiagnosticsControllerTests
{
[Fact]
public async Task TestSquidWtfEndpoints_WithoutAdministratorSession_ReturnsForbidden()
{
var controller = CreateController(
CreateHttpContextWithSession(isAdmin: false),
_ => new HttpResponseMessage(HttpStatusCode.OK));
var result = await controller.TestSquidWtfEndpoints(CancellationToken.None);
var forbidden = Assert.IsType<ObjectResult>(result);
Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode);
}
[Fact]
public async Task TestSquidWtfEndpoints_ReturnsIndependentApiAndStreamingResults()
{
var controller = CreateController(
CreateHttpContextWithSession(isAdmin: true),
request =>
{
var uri = request.RequestUri!;
if (uri.Host == "node-one.example" && uri.AbsolutePath == "/search/")
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
"""
{"data":{"items":[{"id":227242909,"title":"Monica Lewinsky"}]}}
""",
Encoding.UTF8,
"application/json")
};
}
if (uri.Host == "node-one.example" && uri.AbsolutePath == "/track/")
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
"""
{"data":{"manifest":"ZmFrZS1tYW5pZmVzdA=="}}
""",
Encoding.UTF8,
"application/json")
};
}
if (uri.Host == "node-two.example" && uri.AbsolutePath == "/search/")
{
return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable);
}
if (uri.Host == "node-two.example" && uri.AbsolutePath == "/track/")
{
return new HttpResponseMessage(HttpStatusCode.GatewayTimeout);
}
throw new InvalidOperationException($"Unexpected request URI: {uri}");
});
var result = await controller.TestSquidWtfEndpoints(CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
var payload = Assert.IsType<SquidWtfEndpointHealthResponse>(ok.Value);
Assert.Equal(2, payload.TotalRows);
var nodeOne = Assert.Single(payload.Endpoints, e => e.Host == "node-one.example");
Assert.True(nodeOne.Api.Configured);
Assert.True(nodeOne.Api.IsUp);
Assert.Equal("up", nodeOne.Api.State);
Assert.Equal(200, nodeOne.Api.StatusCode);
Assert.True(nodeOne.Streaming.Configured);
Assert.True(nodeOne.Streaming.IsUp);
Assert.Equal("up", nodeOne.Streaming.State);
Assert.Equal(200, nodeOne.Streaming.StatusCode);
var nodeTwo = Assert.Single(payload.Endpoints, e => e.Host == "node-two.example");
Assert.True(nodeTwo.Api.Configured);
Assert.False(nodeTwo.Api.IsUp);
Assert.Equal("down", nodeTwo.Api.State);
Assert.Equal(503, nodeTwo.Api.StatusCode);
Assert.True(nodeTwo.Streaming.Configured);
Assert.False(nodeTwo.Streaming.IsUp);
Assert.Equal("down", nodeTwo.Streaming.State);
Assert.Equal(504, nodeTwo.Streaming.StatusCode);
}
private static HttpContext CreateHttpContextWithSession(bool isAdmin)
{
var context = new DefaultHttpContext();
context.Connection.LocalPort = 5275;
context.Items[AdminAuthSessionService.HttpContextSessionItemKey] = new AdminAuthSession
{
SessionId = "session-id",
UserId = "user-id",
UserName = "user",
IsAdministrator = isAdmin,
JellyfinAccessToken = "token",
JellyfinServerId = "server-id",
ExpiresAtUtc = DateTime.UtcNow.AddHours(1),
LastSeenUtc = DateTime.UtcNow
};
return context;
}
private static DiagnosticsController CreateController(
HttpContext httpContext,
Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
{
var logger = new Mock<ILogger<DiagnosticsController>>();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
var webHostEnvironment = new Mock<IWebHostEnvironment>();
webHostEnvironment.SetupGet(e => e.EnvironmentName).Returns(Environments.Development);
webHostEnvironment.SetupGet(e => e.ContentRootPath).Returns(Directory.GetCurrentDirectory());
var helperLogger = new Mock<ILogger<AdminHelperService>>();
var helperService = new AdminHelperService(
helperLogger.Object,
Options.Create(new JellyfinSettings()),
webHostEnvironment.Object);
var spotifyCookieLogger = new Mock<ILogger<SpotifySessionCookieService>>();
var spotifySessionCookieService = new SpotifySessionCookieService(
Options.Create(new SpotifyApiSettings()),
helperService,
spotifyCookieLogger.Object);
var redisLogger = new Mock<ILogger<RedisCacheService>>();
var redisCache = new RedisCacheService(
Options.Create(new RedisSettings
{
Enabled = false,
ConnectionString = "localhost:6379"
}),
redisLogger.Object,
new Microsoft.Extensions.Caching.Memory.MemoryCache(
new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
var httpClientFactory = new Mock<IHttpClientFactory>();
httpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>()))
.Returns(() => new HttpClient(new StubHttpMessageHandler(responseFactory)));
var controller = new DiagnosticsController(
logger.Object,
configuration,
Options.Create(new SpotifyApiSettings()),
Options.Create(new SpotifyImportSettings()),
Options.Create(new JellyfinSettings()),
Options.Create(new DeezerSettings()),
Options.Create(new QobuzSettings()),
Options.Create(new SquidWTFSettings()),
spotifySessionCookieService,
new SquidWtfEndpointCatalog(
new List<string>
{
"https://node-one.example",
"https://node-two.example"
},
new List<string>
{
"https://node-one.example",
"https://node-two.example"
}),
redisCache,
httpClientFactory.Object)
{
ControllerContext = new ControllerContext
{
HttpContext = httpContext
}
};
return controller;
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responseFactory;
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
{
_responseFactory = responseFactory;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
return Task.FromResult(_responseFactory(request));
}
}
}
@@ -0,0 +1,193 @@
using Moq;
using Microsoft.Extensions.Logging;
using allstarr.Models.Domain;
using allstarr.Services;
using allstarr.Services.Jellyfin;
namespace allstarr.Tests;
public class ExternalArtistAppearancesServiceTests
{
private readonly Mock<IMusicMetadataService> _metadataService = new();
private readonly ExternalArtistAppearancesService _service;
public ExternalArtistAppearancesServiceTests()
{
_service = new ExternalArtistAppearancesService(
_metadataService.Object,
Mock.Of<ILogger<ExternalArtistAppearancesService>>());
}
[Fact]
public async Task GetAppearsOnAlbumsAsync_FiltersPrimaryAlbumsAndDeduplicatesTrackDerivedAlbums()
{
var artist = new Artist
{
Id = "ext-squidwtf-artist-artist-a",
Name = "Artist A"
};
_metadataService
.Setup(service => service.GetArtistAsync("squidwtf", "artist-a", It.IsAny<CancellationToken>()))
.ReturnsAsync(artist);
_metadataService
.Setup(service => service.GetArtistAlbumsAsync("squidwtf", "artist-a", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Album>
{
new()
{
Id = "ext-squidwtf-album-own",
Title = "Own Album",
Artist = "Artist A",
ArtistId = artist.Id,
Year = 2024,
IsLocal = false,
ExternalProvider = "squidwtf",
ExternalId = "own"
},
new()
{
Id = "ext-squidwtf-album-feature",
Title = "Feature Album",
Artist = "Artist B",
ArtistId = "ext-squidwtf-artist-artist-b",
Year = 2023,
IsLocal = false,
ExternalProvider = "squidwtf",
ExternalId = "feature"
}
});
_metadataService
.Setup(service => service.GetArtistTracksAsync("squidwtf", "artist-a", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Song>
{
new()
{
AlbumId = "ext-squidwtf-album-own",
Album = "Own Album",
AlbumArtist = "Artist A",
Title = "Own Song",
Year = 2024
},
new()
{
AlbumId = "ext-squidwtf-album-feature",
Album = "Feature Album",
AlbumArtist = "Artist B",
Title = "Feature Song 1",
Year = 2023
},
new()
{
AlbumId = "ext-squidwtf-album-feature",
Album = "Feature Album",
AlbumArtist = "Artist B",
Title = "Feature Song 2",
Year = 2023
},
new()
{
AlbumId = "ext-squidwtf-album-comp",
Album = "Compilation",
AlbumArtist = "Various Artists",
Title = "Compilation Song",
Year = 2022,
CoverArtUrl = "https://example.com/cover.jpg",
TotalTracks = 10
}
});
var result = await _service.GetAppearsOnAlbumsAsync("squidwtf", "artist-a");
Assert.Collection(
result,
album =>
{
Assert.Equal("Feature Album", album.Title);
Assert.Equal("Artist B", album.Artist);
Assert.Equal("feature", album.ExternalId);
},
album =>
{
Assert.Equal("Compilation", album.Title);
Assert.Equal("Various Artists", album.Artist);
Assert.Equal("comp", album.ExternalId);
Assert.Equal(10, album.SongCount);
});
}
[Fact]
public async Task GetAppearsOnAlbumsAsync_WhenTrackDataIsUnavailable_FallsBackToNonPrimaryAlbumsFromAlbumList()
{
var artist = new Artist
{
Id = "ext-qobuz-artist-artist-a",
Name = "Artist A"
};
_metadataService
.Setup(service => service.GetArtistAsync("qobuz", "artist-a", It.IsAny<CancellationToken>()))
.ReturnsAsync(artist);
_metadataService
.Setup(service => service.GetArtistAlbumsAsync("qobuz", "artist-a", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Album>
{
new()
{
Id = "ext-qobuz-album-own",
Title = "Own Album",
Artist = "Artist A",
ArtistId = artist.Id,
Year = 2024,
IsLocal = false,
ExternalProvider = "qobuz",
ExternalId = "own"
},
new()
{
Id = "ext-qobuz-album-feature",
Title = "Feature Album",
Artist = "Artist C",
ArtistId = "ext-qobuz-artist-artist-c",
Year = 2021,
IsLocal = false,
ExternalProvider = "qobuz",
ExternalId = "feature"
}
});
_metadataService
.Setup(service => service.GetArtistTracksAsync("qobuz", "artist-a", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Song>());
var result = await _service.GetAppearsOnAlbumsAsync("qobuz", "artist-a");
var album = Assert.Single(result);
Assert.Equal("Feature Album", album.Title);
Assert.Equal("Artist C", album.Artist);
Assert.Equal("feature", album.ExternalId);
}
[Fact]
public async Task GetAppearsOnAlbumsAsync_WhenArtistLookupFails_ReturnsEmpty()
{
_metadataService
.Setup(service => service.GetArtistAsync("squidwtf", "missing", It.IsAny<CancellationToken>()))
.ReturnsAsync((Artist?)null);
_metadataService
.Setup(service => service.GetArtistAlbumsAsync("squidwtf", "missing", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Album>());
_metadataService
.Setup(service => service.GetArtistTracksAsync("squidwtf", "missing", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Song>());
var result = await _service.GetAppearsOnAlbumsAsync("squidwtf", "missing");
Assert.Empty(result);
}
}
@@ -0,0 +1,64 @@
using Microsoft.AspNetCore.Http;
using allstarr.Services.Common;
namespace allstarr.Tests;
public class ImageConditionalRequestHelperTests
{
[Fact]
public void ComputeStrongETag_SamePayload_ReturnsStableQuotedHash()
{
var payload = new byte[] { 1, 2, 3, 4 };
var first = ImageConditionalRequestHelper.ComputeStrongETag(payload);
var second = ImageConditionalRequestHelper.ComputeStrongETag(payload);
Assert.Equal(first, second);
Assert.StartsWith("\"", first);
Assert.EndsWith("\"", first);
}
[Fact]
public void MatchesIfNoneMatch_WithExactMatch_ReturnsTrue()
{
var headers = new HeaderDictionary
{
["If-None-Match"] = "\"ABC123\""
};
Assert.True(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"ABC123\""));
}
[Fact]
public void MatchesIfNoneMatch_WithMultipleValues_ReturnsTrueForMatchingEntry()
{
var headers = new HeaderDictionary
{
["If-None-Match"] = "\"stale\", \"fresh\""
};
Assert.True(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"fresh\""));
}
[Fact]
public void MatchesIfNoneMatch_WithWildcard_ReturnsTrue()
{
var headers = new HeaderDictionary
{
["If-None-Match"] = "*"
};
Assert.True(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"anything\""));
}
[Fact]
public void MatchesIfNoneMatch_WithoutMatch_ReturnsFalse()
{
var headers = new HeaderDictionary
{
["If-None-Match"] = "\"ABC123\""
};
Assert.False(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"XYZ789\""));
}
}
@@ -0,0 +1,43 @@
using System.Reflection;
using allstarr.Controllers;
namespace allstarr.Tests;
public class JellyfinControllerSearchLimitTests
{
[Theory]
[InlineData(null, 20, true, 20, 20, 20)]
[InlineData("MusicAlbum", 20, true, 0, 20, 0)]
[InlineData("Audio", 20, true, 20, 0, 0)]
[InlineData("MusicArtist", 20, true, 0, 0, 20)]
[InlineData("Playlist", 20, true, 0, 20, 0)]
[InlineData("Playlist", 20, false, 0, 0, 0)]
[InlineData("Audio,MusicArtist", 15, true, 15, 0, 15)]
[InlineData("BoxSet", 10, true, 0, 0, 0)]
public void GetExternalSearchLimits_UsesRequestedItemTypes(
string? includeItemTypes,
int limit,
bool includePlaylistsAsAlbums,
int expectedSongLimit,
int expectedAlbumLimit,
int expectedArtistLimit)
{
var requestedTypes = string.IsNullOrWhiteSpace(includeItemTypes)
? null
: includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var method = typeof(JellyfinController).GetMethod(
"GetExternalSearchLimits",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.NotNull(method);
var result = ((int SongLimit, int AlbumLimit, int ArtistLimit))method!.Invoke(
null,
new object?[] { requestedTypes, limit, includePlaylistsAsAlbums })!;
Assert.Equal(expectedSongLimit, result.SongLimit);
Assert.Equal(expectedAlbumLimit, result.AlbumLimit);
Assert.Equal(expectedArtistLimit, result.ArtistLimit);
}
}
+74 -5
View File
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.DependencyInjection;
@@ -20,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()
{
@@ -31,7 +33,7 @@ public class JellyfinProxyServiceTests
var redisSettings = new RedisSettings { Enabled = false };
var mockCacheLogger = new Mock<ILogger<RedisCacheService>>();
_cache = new RedisCacheService(Options.Create(redisSettings), mockCacheLogger.Object);
_cache = new RedisCacheService(Options.Create(redisSettings), mockCacheLogger.Object, new MemoryCache(new MemoryCacheOptions()));
_settings = new JellyfinSettings
{
@@ -45,19 +47,21 @@ 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();
serviceCollection.Configure<CacheSettings>(options => { }); // Use defaults
var serviceProvider = serviceCollection.BuildServiceProvider();
CacheExtensions.InitializeCacheSettings(serviceProvider);
allstarr.Services.Common.CacheExtensions.InitializeCacheSettings(serviceProvider);
_service = new JellyfinProxyService(
_mockHttpClientFactory.Object,
Options.Create(_settings),
httpContextAccessor,
_httpContextAccessor,
userResolver,
mockLogger.Object,
_cache);
}
@@ -93,6 +97,21 @@ public class JellyfinProxyServiceTests
Assert.Equal(500, statusCode);
}
[Fact]
public async Task GetJsonAsync_ServerErrorWithJsonBody_ReturnsParsedErrorDocument()
{
// Arrange
SetupMockResponse(HttpStatusCode.Unauthorized, "{\"Message\":\"Token expired\"}", "application/json");
// Act
var (body, statusCode) = await _service.GetJsonAsync("Items");
// Assert
Assert.NotNull(body);
Assert.Equal(401, statusCode);
Assert.Equal("Token expired", body.RootElement.GetProperty("Message").GetString());
}
[Fact]
public async Task GetJsonAsync_WithoutClientHeaders_SendsNoAuth()
{
@@ -228,6 +247,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()
{
@@ -629,12 +686,14 @@ public class JellyfinProxyServiceTests
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
var redisSettings = new RedisSettings { Enabled = false };
var mockCacheLogger = new Mock<ILogger<RedisCacheService>>();
var cache = new RedisCacheService(Options.Create(redisSettings), mockCacheLogger.Object);
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);
@@ -660,4 +719,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);
}
}
@@ -1,5 +1,7 @@
using System.Reflection;
using System.Text.Json;
using allstarr.Controllers;
using allstarr.Models.Jellyfin;
namespace allstarr.Tests;
@@ -8,16 +10,16 @@ public class JellyfinSearchResponseSerializationTests
[Fact]
public void SerializeSearchResponseJson_PreservesPascalCaseShape()
{
var payload = new
var payload = new JellyfinItemsResponse
{
Items = new[]
{
Items =
[
new Dictionary<string, object?>
{
["Name"] = "BTS",
["Type"] = "MusicAlbum"
}
},
],
TotalRecordCount = 1,
StartIndex = 0
};
@@ -28,11 +30,64 @@ public class JellyfinSearchResponseSerializationTests
Assert.NotNull(method);
var closedMethod = method!.MakeGenericMethod(payload.GetType());
var json = (string)closedMethod.Invoke(null, new object?[] { payload })!;
var json = (string)method!.Invoke(null, new object?[] { payload })!;
Assert.Equal(
"{\"Items\":[{\"Name\":\"BTS\",\"Type\":\"MusicAlbum\"}],\"TotalRecordCount\":1,\"StartIndex\":0}",
json);
}
[Fact]
public void SerializeSearchResponseJson_FallsBackForMixedRuntimeShapes()
{
using var rawDoc = JsonDocument.Parse("""
{
"ServerId": "c17d351d3af24c678a6d8049c212d522",
"RunTimeTicks": 2234068710
}
""");
var payload = new JellyfinItemsResponse
{
Items =
[
new Dictionary<string, object?>
{
["Name"] = "Harleys in Hawaii",
["Type"] = "MusicAlbum",
["MediaSources"] = new Dictionary<string, object?>[]
{
new Dictionary<string, object?>
{
["RunTimeTicks"] = 2234068710L,
["MediaAttachments"] = new List<object>(),
["Formats"] = new List<string>(),
["RequiredHttpHeaders"] = new Dictionary<string, string>()
}
},
["ArtistItems"] = new List<object>
{
new Dictionary<string, object?> { ["Name"] = "Katy Perry" }
},
["RawItem"] = rawDoc.RootElement.Clone()
}
],
TotalRecordCount = 1,
StartIndex = 0
};
var method = typeof(JellyfinController).GetMethod(
"SerializeSearchResponseJson",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(method);
var json = (string)method!.Invoke(null, new object?[] { payload })!;
Assert.Contains("\"Items\":[{", json);
Assert.Contains("\"MediaAttachments\":[]", json);
Assert.Contains("\"ArtistItems\":[{\"Name\":\"Katy Perry\"}]", json);
Assert.Contains("\"RawItem\":{\"ServerId\":\"c17d351d3af24c678a6d8049c212d522\",\"RunTimeTicks\":2234068710}", json);
Assert.Contains("\"TotalRecordCount\":1", json);
}
}
+23 -2
View File
@@ -52,9 +52,17 @@ public class JellyfinSessionManagerTests
public async Task RemoveSessionAsync_ReportsPlaybackStopButDoesNotLogoutUserSession()
{
var requestedPaths = new ConcurrentBag<string>();
var requestBodies = new ConcurrentDictionary<string, string>();
var handler = new DelegateHttpMessageHandler((request, _) =>
{
requestedPaths.Add(request.RequestUri?.AbsolutePath ?? string.Empty);
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
requestedPaths.Add(path);
if (request.Content != null)
{
requestBodies[path] = request.Content.ReadAsStringAsync().GetAwaiter().GetResult();
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent));
});
@@ -89,6 +97,12 @@ public class JellyfinSessionManagerTests
Assert.Contains("/Sessions/Capabilities/Full", requestedPaths);
Assert.Contains("/Sessions/Playing/Stopped", requestedPaths);
Assert.DoesNotContain("/Sessions/Logout", requestedPaths);
Assert.Equal(
"{\"PlayableMediaTypes\":[\"Audio\"],\"SupportedCommands\":[\"Play\",\"Playstate\",\"PlayNext\"],\"SupportsMediaControl\":true,\"SupportsPersistentIdentifier\":true,\"SupportsSync\":false}",
requestBodies["/Sessions/Capabilities/Full"]);
Assert.Equal(
"{\"ItemId\":\"item-123\",\"PositionTicks\":42}",
requestBodies["/Sessions/Playing/Stopped"]);
}
[Fact]
@@ -182,12 +196,19 @@ public class JellyfinSessionManagerTests
var cache = new RedisCacheService(
Options.Create(new RedisSettings { Enabled = false }),
NullLogger<RedisCacheService>.Instance);
NullLogger<RedisCacheService>.Instance,
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
return new JellyfinProxyService(
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);
}
+2 -1
View File
@@ -1,5 +1,6 @@
using Xunit;
using Moq;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using allstarr.Services.Lyrics;
using allstarr.Services.Common;
@@ -23,7 +24,7 @@ public class LrclibServiceTests
// Create mock Redis cache
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false });
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object);
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object, new MemoryCache(new MemoryCacheOptions()));
_httpClient = new HttpClient
{
+200 -16
View File
@@ -1,8 +1,12 @@
using Xunit;
using Moq;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Text.Json;
using allstarr.Models.Domain;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
using allstarr.Models.Settings;
@@ -23,11 +27,19 @@ public class RedisCacheServiceTests
});
}
private RedisCacheService CreateService(IMemoryCache? memoryCache = null, IOptions<RedisSettings>? settings = null)
{
return new RedisCacheService(
settings ?? _settings,
_mockLogger.Object,
memoryCache ?? new MemoryCache(new MemoryCacheOptions()));
}
[Fact]
public void Constructor_InitializesWithSettings()
{
// Act
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
// Assert
Assert.NotNull(service);
@@ -45,7 +57,7 @@ public class RedisCacheServiceTests
});
// Act - Constructor will try to connect but should handle failure gracefully
var service = new RedisCacheService(enabledSettings, _mockLogger.Object);
var service = CreateService(settings: enabledSettings);
// Assert - Service should be created even if connection fails
Assert.NotNull(service);
@@ -55,7 +67,7 @@ public class RedisCacheServiceTests
public async Task GetStringAsync_WhenDisabled_ReturnsNull()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
// Act
var result = await service.GetStringAsync("test:key");
@@ -68,7 +80,7 @@ public class RedisCacheServiceTests
public async Task GetAsync_WhenDisabled_ReturnsNull()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
// Act
var result = await service.GetAsync<TestObject>("test:key");
@@ -81,7 +93,7 @@ public class RedisCacheServiceTests
public async Task SetStringAsync_WhenDisabled_ReturnsFalse()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
// Act
var result = await service.SetStringAsync("test:key", "test value");
@@ -94,7 +106,7 @@ public class RedisCacheServiceTests
public async Task SetAsync_WhenDisabled_ReturnsFalse()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
var testObj = new TestObject { Id = 1, Name = "Test" };
// Act
@@ -108,7 +120,7 @@ public class RedisCacheServiceTests
public async Task DeleteAsync_WhenDisabled_ReturnsFalse()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
// Act
var result = await service.DeleteAsync("test:key");
@@ -121,7 +133,7 @@ public class RedisCacheServiceTests
public async Task ExistsAsync_WhenDisabled_ReturnsFalse()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
// Act
var result = await service.ExistsAsync("test:key");
@@ -134,7 +146,7 @@ public class RedisCacheServiceTests
public async Task DeleteByPatternAsync_WhenDisabled_ReturnsZero()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
// Act
var result = await service.DeleteByPatternAsync("test:*");
@@ -147,7 +159,7 @@ public class RedisCacheServiceTests
public async Task SetStringAsync_WithExpiry_AcceptsTimeSpan()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
var expiry = TimeSpan.FromHours(1);
// Act
@@ -161,7 +173,7 @@ public class RedisCacheServiceTests
public async Task SetAsync_WithExpiry_AcceptsTimeSpan()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
var testObj = new TestObject { Id = 1, Name = "Test" };
var expiry = TimeSpan.FromDays(30);
@@ -176,14 +188,14 @@ public class RedisCacheServiceTests
public void IsEnabled_ReflectsSettings()
{
// Arrange
var disabledService = new RedisCacheService(_settings, _mockLogger.Object);
var disabledService = CreateService();
var enabledSettings = Options.Create(new RedisSettings
{
Enabled = true,
ConnectionString = "localhost:6379"
});
var enabledService = new RedisCacheService(enabledSettings, _mockLogger.Object);
var enabledService = CreateService(settings: enabledSettings);
// Assert
Assert.False(disabledService.IsEnabled);
@@ -194,7 +206,7 @@ public class RedisCacheServiceTests
public async Task GetAsync_DeserializesComplexObjects()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
// Act
var result = await service.GetAsync<ComplexTestObject>("test:complex");
@@ -207,7 +219,7 @@ public class RedisCacheServiceTests
public async Task SetAsync_SerializesComplexObjects()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
var complexObj = new ComplexTestObject
{
Id = 1,
@@ -238,12 +250,184 @@ public class RedisCacheServiceTests
});
// Act
var service = new RedisCacheService(customSettings, _mockLogger.Object);
var service = CreateService(settings: customSettings);
// Assert
Assert.NotNull(service);
}
[Fact]
public async Task SetStringAsync_WhenDisabled_CachesValueInMemory()
{
var service = CreateService();
var setResult = await service.SetStringAsync("test:key", "test value");
var cachedValue = await service.GetStringAsync("test:key");
Assert.False(setResult);
Assert.Equal("test value", cachedValue);
}
[Fact]
public async Task SetAsync_WhenDisabled_CachesSerializedObjectInMemory()
{
var service = CreateService();
var expected = new TestObject { Id = 42, Name = "Tiered" };
var setResult = await service.SetAsync("test:object", expected);
var cachedValue = await service.GetAsync<TestObject>("test:object");
Assert.False(setResult);
Assert.NotNull(cachedValue);
Assert.Equal(expected.Id, cachedValue.Id);
Assert.Equal(expected.Name, cachedValue.Name);
}
[Fact]
public async Task ExistsAsync_WhenValueOnlyExistsInMemory_ReturnsTrue()
{
var service = CreateService();
await service.SetStringAsync("test:key", "test value");
var exists = await service.ExistsAsync("test:key");
Assert.True(exists);
}
[Fact]
public async Task DeleteAsync_WhenValueOnlyExistsInMemory_EvictsEntry()
{
var service = CreateService();
await service.SetStringAsync("test:key", "test value");
var deleted = await service.DeleteAsync("test:key");
var cachedValue = await service.GetStringAsync("test:key");
Assert.False(deleted);
Assert.Null(cachedValue);
}
[Fact]
public async Task DeleteByPatternAsync_WhenValuesOnlyExistInMemory_RemovesMatchingEntries()
{
var service = CreateService();
await service.SetStringAsync("search:one", "1");
await service.SetStringAsync("search:two", "2");
await service.SetStringAsync("other:one", "3");
var deletedCount = await service.DeleteByPatternAsync("search:*");
Assert.Equal(2, deletedCount);
Assert.Null(await service.GetStringAsync("search:one"));
Assert.Null(await service.GetStringAsync("search:two"));
Assert.Equal("3", await service.GetStringAsync("other:one"));
}
[Fact]
public async Task SetStringAsync_ImageKeysDoNotUseMemoryCache()
{
var service = CreateService();
await service.SetStringAsync("image:test:key", "binary-ish");
var cachedValue = await service.GetStringAsync("image:test:key");
var exists = await service.ExistsAsync("image:test:key");
Assert.Null(cachedValue);
Assert.False(exists);
}
[Fact]
public async Task SetAsync_WhenSongContainsRawJellyfinMetadata_CachesSerializedValueInMemory()
{
var service = CreateService();
var songs = new List<Song> { CreateLocalSongWithRawJellyfinMetadata() };
var setResult = await service.SetAsync("test:songs:raw-jellyfin", songs);
var cachedValue = await service.GetAsync<List<Song>>("test:songs:raw-jellyfin");
Assert.False(setResult);
Assert.NotNull(cachedValue);
var roundTrippedSong = Assert.Single(cachedValue!);
Assert.True(JellyfinItemSnapshotHelper.TryGetClonedRawItemSnapshot(roundTrippedSong, out var rawItem));
Assert.Equal("song-1", ((JsonElement)rawItem["Id"]!).GetString());
var mediaSources = Assert.IsType<JsonElement>(roundTrippedSong.JellyfinMetadata!["MediaSources"]);
Assert.Equal(JsonValueKind.Array, mediaSources.ValueKind);
Assert.Equal(2234068710L, mediaSources[0].GetProperty("RunTimeTicks").GetInt64());
}
[Fact]
public async Task SetAsync_WhenMatchedTracksContainRawJellyfinMetadata_CachesSerializedValueInMemory()
{
var service = CreateService();
var matchedTracks = new List<MatchedTrack>
{
new()
{
Position = 0,
SpotifyId = "spotify-1",
SpotifyTitle = "Track",
SpotifyArtist = "Artist",
MatchType = "fuzzy",
MatchedSong = CreateLocalSongWithRawJellyfinMetadata()
}
};
var setResult = await service.SetAsync("test:matched:raw-jellyfin", matchedTracks);
var cachedValue = await service.GetAsync<List<MatchedTrack>>("test:matched:raw-jellyfin");
Assert.False(setResult);
Assert.NotNull(cachedValue);
var roundTrippedMatch = Assert.Single(cachedValue!);
Assert.True(
JellyfinItemSnapshotHelper.TryGetClonedRawItemSnapshot(roundTrippedMatch.MatchedSong, out var rawItem));
Assert.Equal("song-1", ((JsonElement)rawItem["Id"]!).GetString());
var mediaSources =
Assert.IsType<JsonElement>(roundTrippedMatch.MatchedSong.JellyfinMetadata!["MediaSources"]);
Assert.Equal(JsonValueKind.Array, mediaSources.ValueKind);
Assert.Equal("song-1", mediaSources[0].GetProperty("Id").GetString());
}
private static Song CreateLocalSongWithRawJellyfinMetadata()
{
var song = new Song
{
Id = "song-1",
Title = "Track",
Artist = "Artist",
Album = "Album",
IsLocal = true
};
using var doc = JsonDocument.Parse("""
{
"Id": "song-1",
"ServerId": "c17d351d3af24c678a6d8049c212d522",
"RunTimeTicks": 2234068710,
"MediaSources": [
{
"Id": "song-1",
"RunTimeTicks": 2234068710
}
]
}
""");
JellyfinItemSnapshotHelper.StoreRawItemSnapshot(song, doc.RootElement);
song.JellyfinMetadata ??= new Dictionary<string, object?>();
song.JellyfinMetadata["MediaSources"] =
JsonSerializer.Deserialize<object>(doc.RootElement.GetProperty("MediaSources").GetRawText());
return song;
}
private class TestObject
{
public int Id { get; set; }
+2 -1
View File
@@ -2,6 +2,7 @@ using Xunit;
using Moq;
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using allstarr.Services.Spotify;
@@ -30,7 +31,7 @@ public class SpotifyMappingServiceTests
ConnectionString = "localhost:6379"
});
_cache = new RedisCacheService(redisSettings, _mockCacheLogger.Object);
_cache = new RedisCacheService(redisSettings, _mockCacheLogger.Object, new MemoryCache(new MemoryCacheOptions()));
_service = new SpotifyMappingService(_cache, _mockLogger.Object);
}
@@ -0,0 +1,90 @@
using allstarr.Models.Settings;
using allstarr.Services.Common;
namespace allstarr.Tests;
public class SpotifyPlaylistScopeResolverTests
{
[Fact]
public void ResolveConfig_PrefersExactJellyfinIdOverDuplicatePlaylistName()
{
var settings = new SpotifyImportSettings
{
Playlists =
{
new SpotifyPlaylistConfig
{
Name = "Discover Weekly",
Id = "spotify-a",
JellyfinId = "jellyfin-a",
UserId = "user-a"
},
new SpotifyPlaylistConfig
{
Name = "Discover Weekly",
Id = "spotify-b",
JellyfinId = "jellyfin-b",
UserId = "user-b"
}
}
};
var resolved = SpotifyPlaylistScopeResolver.ResolveConfig(
settings,
"Discover Weekly",
userId: "user-a",
jellyfinPlaylistId: "jellyfin-b");
Assert.NotNull(resolved);
Assert.Equal("spotify-b", resolved!.Id);
Assert.Equal("jellyfin-b", resolved.JellyfinId);
Assert.Equal("user-b", resolved.UserId);
}
[Fact]
public void GetUserId_PrefersConfiguredValueAndTrimsFallback()
{
var configured = new SpotifyPlaylistConfig
{
UserId = " configured-user "
};
Assert.Equal("configured-user", SpotifyPlaylistScopeResolver.GetUserId(configured, "fallback"));
Assert.Equal("fallback-user", SpotifyPlaylistScopeResolver.GetUserId(null, " fallback-user "));
Assert.Null(SpotifyPlaylistScopeResolver.GetUserId(null, " "));
}
[Fact]
public void GetUserId_DoesNotApplyRequestFallbackToGlobalConfiguredPlaylist()
{
var globalConfiguredPlaylist = new SpotifyPlaylistConfig
{
Name = "On Repeat",
JellyfinId = "7c2b218bd69b00e24c986363ba71852f"
};
Assert.Null(SpotifyPlaylistScopeResolver.GetUserId(
globalConfiguredPlaylist,
"1635cd7d23144ba08251ebe22a56119e"));
}
[Fact]
public void GetScopeId_PrefersJellyfinIdThenSpotifyIdThenFallback()
{
var jellyfinScoped = new SpotifyPlaylistConfig
{
Id = "spotify-id",
JellyfinId = " jellyfin-id "
};
var spotifyScoped = new SpotifyPlaylistConfig
{
Id = " spotify-id "
};
Assert.Equal("jellyfin-id", SpotifyPlaylistScopeResolver.GetScopeId(jellyfinScoped, "fallback"));
Assert.Equal("spotify-id", SpotifyPlaylistScopeResolver.GetScopeId(spotifyScoped, "fallback"));
Assert.Equal("fallback-id", SpotifyPlaylistScopeResolver.GetScopeId(null, " fallback-id "));
Assert.Null(SpotifyPlaylistScopeResolver.GetScopeId(null, " "));
}
}
@@ -85,7 +85,8 @@ public class SquidWTFDownloadServiceTests : IDisposable
var cache = new RedisCacheService(
Options.Create(new RedisSettings { Enabled = false }),
_redisLoggerMock.Object);
_redisLoggerMock.Object,
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
var odesliService = new OdesliService(_httpClientFactoryMock.Object, _odesliLoggerMock.Object, cache);
+64 -1
View File
@@ -1,5 +1,6 @@
using Xunit;
using Moq;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using allstarr.Services.SquidWTF;
@@ -42,7 +43,10 @@ public class SquidWTFMetadataServiceTests
// Create mock Redis cache
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false });
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object);
_mockCache = new Mock<RedisCacheService>(
mockRedisSettings,
mockRedisLogger.Object,
new MemoryCache(new MemoryCacheOptions()));
_apiUrls = new List<string>
{
@@ -299,6 +303,65 @@ public class SquidWTFMetadataServiceTests
Assert.NotNull(result);
}
[Fact]
public async Task SearchAllAsync_WithZeroLimits_SkipsUnusedBuckets()
{
var requestKinds = new List<string>();
var handler = new StubHttpMessageHandler(request =>
{
var trackQuery = GetQueryParameter(request.RequestUri!, "s");
var albumQuery = GetQueryParameter(request.RequestUri!, "al");
var artistQuery = GetQueryParameter(request.RequestUri!, "a");
if (!string.IsNullOrWhiteSpace(trackQuery))
{
requestKinds.Add("song");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(CreateTrackSearchResponse(CreateTrackPayload(1, "Song", "USRC12345678")))
};
}
if (!string.IsNullOrWhiteSpace(albumQuery))
{
requestKinds.Add("album");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(CreateAlbumSearchResponse())
};
}
if (!string.IsNullOrWhiteSpace(artistQuery))
{
requestKinds.Add("artist");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(CreateArtistSearchResponse())
};
}
throw new InvalidOperationException($"Unexpected request URI: {request.RequestUri}");
});
var httpClient = new HttpClient(handler);
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var service = new SquidWTFMetadataService(
_mockHttpClientFactory.Object,
_subsonicSettings,
_squidwtfSettings,
_mockLogger.Object,
_mockCache.Object,
new List<string> { "https://test1.example.com" });
var result = await service.SearchAllAsync("OK Computer", 0, 5, 0);
Assert.Empty(result.Songs);
Assert.Single(result.Albums);
Assert.Empty(result.Artists);
Assert.Equal(new[] { "album" }, requestKinds);
}
[Fact]
public void ExplicitFilter_RespectsSettings()
{
+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)
+293 -1
View File
@@ -9,7 +9,9 @@ using allstarr.Services.Admin;
using allstarr.Services.Spotify;
using allstarr.Services.Scrobbling;
using allstarr.Services.SquidWTF;
using System.Diagnostics;
using System.Runtime;
using System.Text.Json;
namespace allstarr.Controllers;
@@ -18,6 +20,9 @@ namespace allstarr.Controllers;
[ServiceFilter(typeof(AdminPortFilter))]
public class DiagnosticsController : ControllerBase
{
private const string SquidWtfProbeSearchQuery = "22 Taylor Swift";
private const string SquidWtfProbeTrackId = "227242909";
private const string SquidWtfProbeQuality = "LOW";
private readonly ILogger<DiagnosticsController> _logger;
private readonly IConfiguration _configuration;
private readonly SpotifyApiSettings _spotifyApiSettings;
@@ -29,6 +34,8 @@ public class DiagnosticsController : ControllerBase
private readonly RedisCacheService _cache;
private readonly SpotifySessionCookieService _spotifySessionCookieService;
private readonly List<string> _squidWtfApiUrls;
private readonly List<string> _squidWtfStreamingUrls;
private readonly IHttpClientFactory _httpClientFactory;
private static int _urlIndex = 0;
private static readonly object _urlIndexLock = new();
@@ -43,7 +50,8 @@ public class DiagnosticsController : ControllerBase
IOptions<SquidWTFSettings> squidWtfSettings,
SpotifySessionCookieService spotifySessionCookieService,
SquidWtfEndpointCatalog squidWtfEndpointCatalog,
RedisCacheService cache)
RedisCacheService cache,
IHttpClientFactory httpClientFactory)
{
_logger = logger;
_configuration = configuration;
@@ -56,6 +64,8 @@ public class DiagnosticsController : ControllerBase
_spotifySessionCookieService = spotifySessionCookieService;
_cache = cache;
_squidWtfApiUrls = squidWtfEndpointCatalog.ApiUrls;
_squidWtfStreamingUrls = squidWtfEndpointCatalog.StreamingUrls;
_httpClientFactory = httpClientFactory;
}
[HttpGet("status")]
@@ -161,6 +171,61 @@ public class DiagnosticsController : ControllerBase
return Ok(new { baseUrl });
}
[HttpPost("squidwtf/endpoints/test")]
public async Task<IActionResult> TestSquidWtfEndpoints(CancellationToken cancellationToken)
{
var forbidden = RequireAdministratorForSensitiveOperation("squidwtf endpoint diagnostics");
if (forbidden != null)
{
return forbidden;
}
try
{
var rows = BuildSquidWtfEndpointRows();
_logger.LogInformation(
"Starting SquidWTF endpoint diagnostics for {RowCount} hosts ({ApiCount} API URLs, {StreamingCount} streaming URLs)",
rows.Count,
_squidWtfApiUrls.Count,
_squidWtfStreamingUrls.Count);
var probeTasks = rows.Select(row => PopulateProbeResultsAsync(row, cancellationToken));
await Task.WhenAll(probeTasks);
var apiUpCount = rows.Count(row => row.Api.Configured && row.Api.IsUp);
var streamingUpCount = rows.Count(row => row.Streaming.Configured && row.Streaming.IsUp);
_logger.LogInformation(
"Completed SquidWTF endpoint diagnostics: API up {ApiUp}/{ApiConfigured}, streaming up {StreamingUp}/{StreamingConfigured}",
apiUpCount,
rows.Count(row => row.Api.Configured),
streamingUpCount,
rows.Count(row => row.Streaming.Configured));
var response = new SquidWtfEndpointHealthResponse
{
TestedAtUtc = DateTime.UtcNow,
TotalRows = rows.Count,
Endpoints = rows
.OrderBy(r => r.Host, StringComparer.OrdinalIgnoreCase)
.ToList()
};
return Ok(response);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test SquidWTF endpoints");
return StatusCode(StatusCodes.Status500InternalServerError, new
{
error = "Failed to test SquidWTF endpoints"
});
}
}
/// <summary>
/// Get current configuration including cache settings
/// </summary>
@@ -423,6 +488,233 @@ public class DiagnosticsController : ControllerBase
}
}
private IActionResult? RequireAdministratorForSensitiveOperation(string operationName)
{
if (HttpContext.Items.TryGetValue(AdminAuthSessionService.HttpContextSessionItemKey, out var sessionObj) &&
sessionObj is AdminAuthSession session &&
session.IsAdministrator)
{
return null;
}
_logger.LogWarning("Blocked sensitive admin operation '{Operation}' due to missing administrator session", operationName);
return StatusCode(StatusCodes.Status403Forbidden, new
{
error = "Administrator permissions required",
message = "This operation is restricted to Jellyfin administrators."
});
}
private List<SquidWtfEndpointHealthRow> BuildSquidWtfEndpointRows()
{
var rows = new Dictionary<string, SquidWtfEndpointHealthRow>(StringComparer.OrdinalIgnoreCase);
foreach (var apiUrl in _squidWtfApiUrls)
{
var key = GetEndpointKey(apiUrl);
if (!rows.TryGetValue(key, out var row))
{
row = new SquidWtfEndpointHealthRow
{
Host = key
};
rows[key] = row;
}
row.ApiUrl = apiUrl;
}
foreach (var streamingUrl in _squidWtfStreamingUrls)
{
var key = GetEndpointKey(streamingUrl);
if (!rows.TryGetValue(key, out var row))
{
row = new SquidWtfEndpointHealthRow
{
Host = key
};
rows[key] = row;
}
row.StreamingUrl = streamingUrl;
}
return rows.Values.ToList();
}
private async Task PopulateProbeResultsAsync(SquidWtfEndpointHealthRow row, CancellationToken cancellationToken)
{
var apiTask = ProbeApiEndpointAsync(row.ApiUrl, cancellationToken);
var streamingTask = ProbeStreamingEndpointAsync(row.StreamingUrl, cancellationToken);
await Task.WhenAll(apiTask, streamingTask);
row.Api = await apiTask;
row.Streaming = await streamingTask;
var anyFailure = (row.Api.Configured && !row.Api.IsUp) ||
(row.Streaming.Configured && !row.Streaming.IsUp);
_logger.Log(
anyFailure ? LogLevel.Warning : LogLevel.Information,
"SquidWTF probe {Host}: API {ApiState} ({ApiStatusCode}, {ApiLatencyMs}ms{ApiErrorSuffix}) | streaming {StreamingState} ({StreamingStatusCode}, {StreamingLatencyMs}ms{StreamingErrorSuffix})",
row.Host,
row.Api.State,
row.Api.StatusCode?.ToString() ?? "n/a",
row.Api.LatencyMs?.ToString() ?? "n/a",
string.IsNullOrWhiteSpace(row.Api.Error) ? string.Empty : $", {row.Api.Error}",
row.Streaming.State,
row.Streaming.StatusCode?.ToString() ?? "n/a",
row.Streaming.LatencyMs?.ToString() ?? "n/a",
string.IsNullOrWhiteSpace(row.Streaming.Error) ? string.Empty : $", {row.Streaming.Error}");
}
private async Task<SquidWtfEndpointProbeResult> ProbeApiEndpointAsync(string? baseUrl, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(baseUrl))
{
return new SquidWtfEndpointProbeResult
{
Configured = false,
State = "missing",
Error = "No API URL configured"
};
}
var requestUrl = $"{baseUrl}/search/?s={Uri.EscapeDataString(SquidWtfProbeSearchQuery)}&limit=1&offset=0";
return await ProbeEndpointAsync(
requestUrl,
response => ResponseContainsSearchItemsAsync(response, cancellationToken),
cancellationToken);
}
private async Task<SquidWtfEndpointProbeResult> ProbeStreamingEndpointAsync(string? baseUrl, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(baseUrl))
{
return new SquidWtfEndpointProbeResult
{
Configured = false,
State = "missing",
Error = "No streaming URL configured"
};
}
var requestUrl = $"{baseUrl}/track/?id={Uri.EscapeDataString(SquidWtfProbeTrackId)}&quality={Uri.EscapeDataString(SquidWtfProbeQuality)}";
return await ProbeEndpointAsync(
requestUrl,
response => ResponseContainsTrackManifestAsync(response, cancellationToken),
cancellationToken);
}
private async Task<SquidWtfEndpointProbeResult> ProbeEndpointAsync(
string requestUrl,
Func<HttpResponseMessage, Task<bool>> isHealthyResponse,
CancellationToken cancellationToken)
{
using var client = CreateDiagnosticsHttpClient();
using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
var stopwatch = Stopwatch.StartNew();
try
{
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
stopwatch.Stop();
var isHealthy = response.IsSuccessStatusCode && await isHealthyResponse(response);
return new SquidWtfEndpointProbeResult
{
Configured = true,
IsUp = isHealthy,
State = isHealthy ? "up" : "down",
StatusCode = (int)response.StatusCode,
LatencyMs = stopwatch.ElapsedMilliseconds,
RequestUrl = requestUrl,
Error = isHealthy ? null : $"Unexpected {(int)response.StatusCode} response"
};
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
stopwatch.Stop();
return new SquidWtfEndpointProbeResult
{
Configured = true,
State = "timeout",
LatencyMs = stopwatch.ElapsedMilliseconds,
RequestUrl = requestUrl,
Error = "Timed out"
};
}
catch (HttpRequestException ex)
{
stopwatch.Stop();
return new SquidWtfEndpointProbeResult
{
Configured = true,
State = "down",
StatusCode = ex.StatusCode.HasValue ? (int)ex.StatusCode.Value : null,
LatencyMs = stopwatch.ElapsedMilliseconds,
RequestUrl = requestUrl,
Error = ex.Message
};
}
catch (Exception ex)
{
stopwatch.Stop();
return new SquidWtfEndpointProbeResult
{
Configured = true,
State = "down",
LatencyMs = stopwatch.ElapsedMilliseconds,
RequestUrl = requestUrl,
Error = ex.Message
};
}
}
private HttpClient CreateDiagnosticsHttpClient()
{
var client = _httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(8);
if (!client.DefaultRequestHeaders.UserAgent.Any())
{
client.DefaultRequestHeaders.UserAgent.ParseAdd("allstarr-admin-diagnostics/1.0");
}
return client;
}
private static async Task<bool> ResponseContainsSearchItemsAsync(
HttpResponseMessage response,
CancellationToken cancellationToken)
{
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
return document.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("items", out var items) &&
items.ValueKind == JsonValueKind.Array;
}
private static async Task<bool> ResponseContainsTrackManifestAsync(
HttpResponseMessage response,
CancellationToken cancellationToken)
{
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
return document.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("manifest", out var manifest) &&
manifest.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(manifest.GetString());
}
private static string GetEndpointKey(string url)
{
if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
return uri.IsDefaultPort ? uri.Host : $"{uri.Host}:{uri.Port}";
}
return url.Trim();
}
/// <summary>
+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())
@@ -1,7 +1,9 @@
using System.Collections.Concurrent;
using System.Text.Json;
using System.Globalization;
using allstarr.Models.Jellyfin;
using allstarr.Models.Scrobbling;
using allstarr.Serialization;
using Microsoft.AspNetCore.Mvc;
namespace allstarr.Controllers;
@@ -226,7 +228,7 @@ public partial class JellyfinController
// Build minimal playback start with just the ghost UUID
// Don't include the Item object - Jellyfin will just track the session without item details
var playbackStart = new
var playbackStart = new JellyfinPlaybackStatePayload
{
ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0,
@@ -236,7 +238,7 @@ public partial class JellyfinController
PlayMethod = "DirectPlay"
};
var playbackJson = JsonSerializer.Serialize(playbackStart);
var playbackJson = AllstarrJsonSerializer.Serialize(playbackStart);
_logger.LogDebug("📤 Sending ghost playback start for external track: {Json}", playbackJson);
// Forward to Jellyfin with ghost UUID
@@ -357,14 +359,13 @@ public partial class JellyfinController
trackName ?? "Unknown", itemId);
// Build playback start info - Jellyfin will fetch item details itself
var playbackStart = new
var playbackStart = new JellyfinPlaybackStatePayload
{
ItemId = itemId,
PositionTicks = positionTicks ?? 0,
// Let Jellyfin fetch the item details - don't include NowPlayingItem
ItemId = itemId ?? string.Empty,
PositionTicks = positionTicks ?? 0
};
var playbackJson = JsonSerializer.Serialize(playbackStart);
var playbackJson = AllstarrJsonSerializer.Serialize(playbackStart);
_logger.LogDebug("📤 Sending playback start: {Json}", playbackJson);
var (result, statusCode) =
@@ -624,7 +625,7 @@ public partial class JellyfinController
externalId);
var inferredStartGhostUuid = GenerateUuidFromString(itemId);
var inferredExternalStartPayload = JsonSerializer.Serialize(new
var inferredExternalStartPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
{
ItemId = inferredStartGhostUuid,
PositionTicks = positionTicks ?? 0,
@@ -692,7 +693,7 @@ public partial class JellyfinController
var ghostUuid = GenerateUuidFromString(itemId);
// Build progress report with ghost UUID
var progressReport = new
var progressReport = new JellyfinPlaybackStatePayload
{
ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0,
@@ -702,7 +703,7 @@ public partial class JellyfinController
PlayMethod = "DirectPlay"
};
var progressJson = JsonSerializer.Serialize(progressReport);
var progressJson = AllstarrJsonSerializer.Serialize(progressReport);
// Forward to Jellyfin with ghost UUID
var (progressResult, progressStatusCode) =
@@ -773,7 +774,7 @@ public partial class JellyfinController
_logger.LogInformation("🎵 Local track playback started (inferred from progress): {Name} (ID: {ItemId})",
trackName ?? "Unknown", itemId);
var inferredStartPayload = JsonSerializer.Serialize(new
var inferredStartPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
{
ItemId = itemId,
PositionTicks = positionTicks ?? 0
@@ -948,7 +949,7 @@ public partial class JellyfinController
}
var ghostUuid = GenerateUuidFromString(previousItemId);
var inferredExternalStopPayload = JsonSerializer.Serialize(new
var inferredExternalStopPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
{
ItemId = ghostUuid,
PositionTicks = previousPositionTicks ?? 0,
@@ -997,7 +998,7 @@ public partial class JellyfinController
});
}
var inferredStopPayload = JsonSerializer.Serialize(new
var inferredStopPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
{
ItemId = previousItemId,
PositionTicks = previousPositionTicks ?? 0,
@@ -1062,7 +1063,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 +1099,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 +1117,7 @@ public partial class JellyfinController
return queryUserId;
}
return _settings.UserId;
return await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
}
private static int? ToPlaybackPositionSeconds(long? positionTicks)
@@ -1294,13 +1295,13 @@ public partial class JellyfinController
// Report stop to Jellyfin with ghost UUID
var ghostUuid = GenerateUuidFromString(itemId);
var externalStopInfo = new
var externalStopInfo = new JellyfinPlaybackStatePayload
{
ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0
};
var stopJson = JsonSerializer.Serialize(externalStopInfo);
var stopJson = AllstarrJsonSerializer.Serialize(externalStopInfo);
_logger.LogDebug("📤 Sending ghost playback stop for external track: {Json}", stopJson);
var (stopResult, stopStatusCode) =
@@ -1469,7 +1470,7 @@ public partial class JellyfinController
stopInfo["PositionTicks"] = positionTicks.Value;
}
body = JsonSerializer.Serialize(stopInfo);
body = AllstarrJsonSerializer.Serialize(stopInfo);
_logger.LogDebug("📤 Sending playback stop body (IsPaused=false, {BodyLength} bytes)", body.Length);
var (result, statusCode) =
+379 -73
View File
@@ -1,7 +1,11 @@
using System.Buffers;
using System.Text.Json;
using System.Text;
using allstarr.Models.Domain;
using allstarr.Models.Jellyfin;
using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using allstarr.Serialization;
using allstarr.Services.Common;
using Microsoft.AspNetCore.Mvc;
@@ -24,6 +28,7 @@ public partial class JellyfinController
[FromQuery] int startIndex = 0,
[FromQuery] string? parentId = null,
[FromQuery] string? artistIds = null,
[FromQuery] string? contributingArtistIds = null,
[FromQuery] string? albumArtistIds = null,
[FromQuery] string? albumIds = null,
[FromQuery] string? sortBy = null,
@@ -38,8 +43,8 @@ public partial class JellyfinController
var effectiveArtistIds = albumArtistIds ?? artistIds;
var favoritesOnlyRequest = IsFavoritesOnlyRequest();
_logger.LogDebug("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, albumArtistIds={AlbumArtistIds}, albumIds={AlbumIds}, userId={UserId}",
searchTerm, includeItemTypes, parentId, artistIds, albumArtistIds, albumIds, userId);
_logger.LogDebug("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, contributingArtistIds={ContributingArtistIds}, albumArtistIds={AlbumArtistIds}, albumIds={AlbumIds}, userId={UserId}",
searchTerm, includeItemTypes, parentId, artistIds, contributingArtistIds, albumArtistIds, albumIds, userId);
_logger.LogInformation(
"SEARCH TRACE: rawQuery='{RawQuery}', boundSearchTerm='{BoundSearchTerm}', effectiveSearchTerm='{EffectiveSearchTerm}', includeItemTypes='{IncludeItemTypes}'",
Request.QueryString.Value ?? string.Empty,
@@ -50,15 +55,34 @@ public partial class JellyfinController
// ============================================================================
// REQUEST ROUTING LOGIC (Priority Order)
// ============================================================================
// 1. ArtistIds present (external) → Handle external artists (even with ParentId)
// 2. AlbumIds present (external) → Handle external albums (even with ParentId)
// 3. ParentId present → GetChildItems (handles external playlists/albums/artists OR proxies library items)
// 4. ArtistIds present (library) → Proxy to Jellyfin with artist filter
// 5. SearchTerm present → Integrated search (Jellyfin + external sources)
// 6. Otherwise → Proxy browse request transparently to Jellyfin
// 1. ContributingArtistIds present (external) → Handle external "appears on" albums
// 2. ArtistIds present (external) → Handle external artists (even with ParentId)
// 3. AlbumIds present (external) → Handle external albums (even with ParentId)
// 4. ParentId present → GetChildItems (handles external playlists/albums/artists OR proxies library items)
// 5. ArtistIds / ContributingArtistIds present (library) → Proxy to Jellyfin with full filter
// 6. SearchTerm present → Integrated search (Jellyfin + external sources)
// 7. Otherwise → Proxy browse request transparently to Jellyfin
// ============================================================================
// PRIORITY 1: External artist filter - takes precedence over everything (including ParentId)
// PRIORITY 1: External contributing artist filter - used by Jellyfin's "Appears on" album requests.
if (!string.IsNullOrWhiteSpace(contributingArtistIds))
{
var contributingArtistId = contributingArtistIds.Split(',')[0];
var (isExternal, provider, type, externalId) = _localLibraryService.ParseExternalId(contributingArtistId);
if (isExternal)
{
_logger.LogDebug(
"Fetching contributing artist albums for external artist: {Provider}/{ExternalId}, type={Type}",
provider,
externalId,
type);
return await GetExternalContributorChildItems(provider!, type!, externalId!, includeItemTypes, HttpContext.RequestAborted);
}
// If library artist, fall through to proxy
}
// PRIORITY 2: External artist filter - takes precedence over everything (including ParentId)
if (!string.IsNullOrWhiteSpace(effectiveArtistIds))
{
var artistId = effectiveArtistIds.Split(',')[0]; // Take first artist if multiple
@@ -86,7 +110,7 @@ public partial class JellyfinController
// If library artist, fall through to handle with ParentId or proxy
}
// PRIORITY 2: External album filter
// PRIORITY 3: External album filter
if (!string.IsNullOrWhiteSpace(albumIds))
{
var albumId = albumIds.Split(',')[0]; // Take first album if multiple
@@ -106,23 +130,17 @@ public partial class JellyfinController
var album = await _metadataService.GetAlbumAsync(provider!, externalId!, HttpContext.RequestAborted);
if (album == null)
{
return new JsonResult(new
{ Items = Array.Empty<object>(), TotalRecordCount = 0, StartIndex = startIndex });
return CreateItemsResponse([], 0, startIndex);
}
var albumItems = album.Songs.Select(song => _responseBuilder.ConvertSongToJellyfinItem(song)).ToList();
return new JsonResult(new
{
Items = albumItems,
TotalRecordCount = albumItems.Count,
StartIndex = startIndex
});
return CreateItemsResponse(albumItems, albumItems.Count, startIndex);
}
// If library album, fall through to handle with ParentId or proxy
}
// PRIORITY 3: ParentId present - check if external first
// PRIORITY 4: ParentId present - check if external first
if (!string.IsNullOrWhiteSpace(parentId))
{
// Check if this is an external playlist
@@ -164,7 +182,17 @@ public partial class JellyfinController
}
}
// PRIORITY 4: Library artist filter (already checked for external above)
// PRIORITY 5: Library artist/contributing-artist filters (already checked for external above)
if (!string.IsNullOrWhiteSpace(contributingArtistIds))
{
_logger.LogDebug("Library contributing artist filter requested, proxying to Jellyfin");
var endpoint = userId != null
? $"Users/{userId}/Items{Request.QueryString}"
: $"Items{Request.QueryString}";
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
return HandleProxyResponse(result, statusCode);
}
if (!string.IsNullOrWhiteSpace(effectiveArtistIds))
{
// Library artist - proxy transparently with full query string
@@ -176,7 +204,7 @@ public partial class JellyfinController
return HandleProxyResponse(result, statusCode);
}
// PRIORITY 5: Search term present - do integrated search (Jellyfin + external)
// PRIORITY 6: Search term present - do integrated search (Jellyfin + external)
if (!string.IsNullOrWhiteSpace(searchTerm))
{
// Check cache for search results (only cache pure searches, not filtered searches)
@@ -204,7 +232,7 @@ public partial class JellyfinController
// Fall through to integrated search below
}
// PRIORITY 6: No filters, no search - proxy browse request transparently
// PRIORITY 7: No filters, no search - proxy browse request transparently
else
{
_logger.LogDebug("Browse request with no filters, proxying to Jellyfin with full query string");
@@ -304,6 +332,7 @@ public partial class JellyfinController
// Run local and external searches in parallel
var itemTypes = ParseItemTypes(includeItemTypes);
var externalSearchLimits = GetExternalSearchLimits(itemTypes, limit, includePlaylistsAsAlbums: true);
var jellyfinTask = GetLocalSearchResultForCurrentRequest(
cleanQuery,
includeItemTypes,
@@ -312,12 +341,29 @@ public partial class JellyfinController
recursive,
userId);
_logger.LogInformation(
"SEARCH TRACE: external limits for query '{Query}' => songs={SongLimit}, albums={AlbumLimit}, artists={ArtistLimit}",
cleanQuery,
externalSearchLimits.SongLimit,
externalSearchLimits.AlbumLimit,
externalSearchLimits.ArtistLimit);
// Use parallel metadata service if available (races providers), otherwise use primary
var externalTask = favoritesOnlyRequest
? Task.FromResult(new SearchResult())
: _parallelMetadataService != null
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted)
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted);
? _parallelMetadataService.SearchAllAsync(
cleanQuery,
externalSearchLimits.SongLimit,
externalSearchLimits.AlbumLimit,
externalSearchLimits.ArtistLimit,
HttpContext.RequestAborted)
: _metadataService.SearchAllAsync(
cleanQuery,
externalSearchLimits.SongLimit,
externalSearchLimits.AlbumLimit,
externalSearchLimits.ArtistLimit,
HttpContext.RequestAborted);
var playlistTask = favoritesOnlyRequest || !_settings.EnableExternalPlaylists
? Task.FromResult(new List<ExternalPlaylist>())
@@ -527,44 +573,20 @@ public partial class JellyfinController
try
{
// Return with PascalCase - use ContentResult to bypass JSON serialization issues
var response = new
var response = new JellyfinItemsResponse
{
Items = pagedItems,
TotalRecordCount = items.Count,
StartIndex = startIndex
};
var json = SerializeSearchResponseJson(response);
// Cache search results in Redis using the configured search TTL.
if (!string.IsNullOrWhiteSpace(searchTerm) &&
string.IsNullOrWhiteSpace(effectiveArtistIds) &&
!string.IsNullOrWhiteSpace(searchCacheKey))
{
if (externalHasRequestedTypeResults)
{
await _cache.SetStringAsync(searchCacheKey, json, CacheExtensions.SearchResultsTTL);
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm,
CacheExtensions.SearchResultsTTL.TotalMinutes);
}
else
{
_logger.LogInformation(
"SEARCH TRACE: skipped cache write for query '{Query}' because requested external result buckets were empty (types={ItemTypes})",
cleanQuery,
includeItemTypes ?? string.Empty);
}
}
_logger.LogDebug("About to serialize response...");
if (_logger.IsEnabled(LogLevel.Debug))
{
var preview = json.Length > 200 ? json[..200] : json;
_logger.LogDebug("JSON response preview: {Json}", preview);
}
return Content(json, "application/json");
return await WriteSearchItemsResponseAsync(
response,
searchTerm,
effectiveArtistIds,
searchCacheKey,
externalHasRequestedTypeResults,
cleanQuery,
includeItemTypes);
}
catch (Exception ex)
{
@@ -573,13 +595,47 @@ public partial class JellyfinController
}
}
private static string SerializeSearchResponseJson<T>(T response) where T : class
private static string SerializeSearchResponseJson(JellyfinItemsResponse response)
{
return JsonSerializer.Serialize(response, new JsonSerializerOptions
return AllstarrJsonSerializer.Serialize(response);
}
private async Task<IActionResult> WriteSearchItemsResponseAsync(
JellyfinItemsResponse response,
string? searchTerm,
string? effectiveArtistIds,
string? searchCacheKey,
bool externalHasRequestedTypeResults,
string cleanQuery,
string? includeItemTypes)
{
var shouldCache = !string.IsNullOrWhiteSpace(searchTerm) &&
string.IsNullOrWhiteSpace(effectiveArtistIds) &&
!string.IsNullOrWhiteSpace(searchCacheKey) &&
externalHasRequestedTypeResults;
Response.StatusCode = StatusCodes.Status200OK;
Response.ContentType = "application/json";
var json = SerializeSearchResponseJson(response);
await Response.WriteAsync(json, Encoding.UTF8);
if (shouldCache)
{
PropertyNamingPolicy = null,
DictionaryKeyPolicy = null
});
await _cache.SetStringAsync(searchCacheKey!, json, CacheExtensions.SearchResultsTTL);
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm,
CacheExtensions.SearchResultsTTL.TotalMinutes);
}
else if (!string.IsNullOrWhiteSpace(searchTerm) &&
string.IsNullOrWhiteSpace(effectiveArtistIds) &&
!string.IsNullOrWhiteSpace(searchCacheKey))
{
_logger.LogInformation(
"SEARCH TRACE: skipped cache write for query '{Query}' because requested external result buckets were empty (types={ItemTypes})",
cleanQuery,
includeItemTypes ?? string.Empty);
}
return new EmptyResult();
}
/// <summary>
@@ -672,11 +728,33 @@ public partial class JellyfinController
}
var cleanQuery = searchTerm.Trim().Trim('"');
var requestedTypes = ParseItemTypes(includeItemTypes);
var externalSearchLimits = GetExternalSearchLimits(requestedTypes, limit, includePlaylistsAsAlbums: false);
var includesSongs = requestedTypes == null || requestedTypes.Length == 0 || requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
var includesAlbums = requestedTypes == null || requestedTypes.Length == 0 || requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase);
var includesArtists = requestedTypes == null || requestedTypes.Length == 0 || requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
_logger.LogInformation(
"SEARCH TRACE: hint limits for query '{Query}' => songs={SongLimit}, albums={AlbumLimit}, artists={ArtistLimit}",
cleanQuery,
externalSearchLimits.SongLimit,
externalSearchLimits.AlbumLimit,
externalSearchLimits.ArtistLimit);
// Use parallel metadata service if available (races providers), otherwise use primary
var externalTask = _parallelMetadataService != null
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted)
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted);
? _parallelMetadataService.SearchAllAsync(
cleanQuery,
externalSearchLimits.SongLimit,
externalSearchLimits.AlbumLimit,
externalSearchLimits.ArtistLimit,
HttpContext.RequestAborted)
: _metadataService.SearchAllAsync(
cleanQuery,
externalSearchLimits.SongLimit,
externalSearchLimits.AlbumLimit,
externalSearchLimits.ArtistLimit,
HttpContext.RequestAborted);
// Run searches in parallel (local Jellyfin hints + external providers)
var jellyfinTask = GetLocalSearchHintsResultForCurrentRequest(cleanQuery, userId);
@@ -689,9 +767,15 @@ public partial class JellyfinController
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseSearchHintsResponse(jellyfinResult);
// NO deduplication - merge all results and take top matches
var allSongs = localSongs.Concat(externalResult.Songs).Take(limit).ToList();
var allAlbums = localAlbums.Concat(externalResult.Albums).Take(limit).ToList();
var allArtists = localArtists.Concat(externalResult.Artists).Take(limit).ToList();
var allSongs = includesSongs
? localSongs.Concat(externalResult.Songs).Take(limit).ToList()
: new List<Song>();
var allAlbums = includesAlbums
? localAlbums.Concat(externalResult.Albums).Take(limit).ToList()
: new List<Album>();
var allArtists = includesArtists
? localArtists.Concat(externalResult.Artists).Take(limit).ToList()
: new List<Artist>();
return _responseBuilder.CreateSearchHintsResponse(
allSongs.Take(limit).ToList(),
@@ -742,16 +826,238 @@ public partial class JellyfinController
return string.Equals(Request.Query["IsFavorite"].ToString(), "true", StringComparison.OrdinalIgnoreCase);
}
private static IActionResult CreateEmptyItemsResponse(int startIndex)
private static (int SongLimit, int AlbumLimit, int ArtistLimit) GetExternalSearchLimits(
string[]? requestedTypes,
int limit,
bool includePlaylistsAsAlbums)
{
return new JsonResult(new
if (limit <= 0)
{
Items = Array.Empty<object>(),
TotalRecordCount = 0,
StartIndex = startIndex
});
return (0, 0, 0);
}
if (requestedTypes == null || requestedTypes.Length == 0)
{
return (limit, limit, limit);
}
var includeSongs = requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
var includeAlbums = requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase) ||
(includePlaylistsAsAlbums && requestedTypes.Contains("Playlist", StringComparer.OrdinalIgnoreCase));
var includeArtists = requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
return (
includeSongs ? limit : 0,
includeAlbums ? limit : 0,
includeArtists ? limit : 0);
}
private static IActionResult CreateEmptyItemsResponse(int startIndex)
{
return CreateItemsResponse([], 0, startIndex);
}
private static ContentResult CreateItemsResponse(
List<Dictionary<string, object?>> items,
int totalRecordCount,
int startIndex)
{
var response = new JellyfinItemsResponse
{
Items = items,
TotalRecordCount = totalRecordCount,
StartIndex = startIndex
};
return new ContentResult
{
Content = SerializeSearchResponseJson(response),
ContentType = "application/json"
};
}
private sealed class TeeBufferWriter : IBufferWriter<byte>
{
private readonly IBufferWriter<byte> _primary;
private readonly ArrayBufferWriter<byte>? _secondary;
private Memory<byte> _currentBuffer;
public TeeBufferWriter(IBufferWriter<byte> primary, ArrayBufferWriter<byte>? secondary)
{
_primary = primary;
_secondary = secondary;
}
public void Advance(int count)
{
if (count > 0 && _secondary != null)
{
var destination = _secondary.GetSpan(count);
_currentBuffer.Span[..count].CopyTo(destination);
_secondary.Advance(count);
}
_primary.Advance(count);
_currentBuffer = Memory<byte>.Empty;
}
public Memory<byte> GetMemory(int sizeHint = 0)
{
_currentBuffer = _primary.GetMemory(sizeHint);
return _currentBuffer;
}
public Span<byte> GetSpan(int sizeHint = 0)
{
return GetMemory(sizeHint).Span;
}
}
private List<Dictionary<string, object?>> ApplyRequestedAlbumOrderingIfApplicable(
List<Dictionary<string, object?>> items,
string[]? requestedTypes,
string? sortBy,
string? sortOrder)
{
if (items.Count <= 1 || string.IsNullOrWhiteSpace(sortBy))
{
return items;
}
if (requestedTypes == null || requestedTypes.Length == 0)
{
return items;
}
var isAlbumOnlyRequest = requestedTypes.All(type =>
string.Equals(type, "MusicAlbum", StringComparison.OrdinalIgnoreCase) ||
string.Equals(type, "Playlist", StringComparison.OrdinalIgnoreCase));
if (!isAlbumOnlyRequest)
{
return items;
}
var sortFields = sortBy
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(field => !string.IsNullOrWhiteSpace(field))
.ToList();
if (sortFields.Count == 0)
{
return items;
}
var descending = string.Equals(sortOrder, "Descending", StringComparison.OrdinalIgnoreCase);
var sorted = items.ToList();
sorted.Sort((left, right) => CompareAlbumItemsByRequestedSort(left, right, sortFields, descending));
return sorted;
}
private int CompareAlbumItemsByRequestedSort(
Dictionary<string, object?> left,
Dictionary<string, object?> right,
IReadOnlyList<string> sortFields,
bool descending)
{
foreach (var field in sortFields)
{
var comparison = CompareAlbumItemsByField(left, right, field);
if (comparison == 0)
{
continue;
}
return descending ? -comparison : comparison;
}
return string.Compare(GetItemStringValue(left, "Name"), GetItemStringValue(right, "Name"), StringComparison.OrdinalIgnoreCase);
}
private int CompareAlbumItemsByField(Dictionary<string, object?> left, Dictionary<string, object?> right, string field)
{
return field.ToLowerInvariant() switch
{
"sortname" => string.Compare(GetItemStringValue(left, "SortName"), GetItemStringValue(right, "SortName"), StringComparison.OrdinalIgnoreCase),
"name" => string.Compare(GetItemStringValue(left, "Name"), GetItemStringValue(right, "Name"), StringComparison.OrdinalIgnoreCase),
"datecreated" => DateTime.Compare(GetItemDateValue(left, "DateCreated"), GetItemDateValue(right, "DateCreated")),
"premieredate" => DateTime.Compare(GetItemDateValue(left, "PremiereDate"), GetItemDateValue(right, "PremiereDate")),
"productionyear" => CompareIntValues(GetItemIntValue(left, "ProductionYear"), GetItemIntValue(right, "ProductionYear")),
_ => 0
};
}
private static int CompareIntValues(int? left, int? right)
{
if (left.HasValue && right.HasValue)
{
return left.Value.CompareTo(right.Value);
}
if (left.HasValue)
{
return 1;
}
if (right.HasValue)
{
return -1;
}
return 0;
}
private static DateTime GetItemDateValue(Dictionary<string, object?> item, string key)
{
if (!item.TryGetValue(key, out var value) || value == null)
{
return DateTime.MinValue;
}
if (value is JsonElement jsonElement)
{
if (jsonElement.ValueKind == JsonValueKind.String &&
DateTime.TryParse(jsonElement.GetString(), out var parsedDate))
{
return parsedDate;
}
return DateTime.MinValue;
}
if (DateTime.TryParse(value.ToString(), out var parsed))
{
return parsed;
}
return DateTime.MinValue;
}
private static int? GetItemIntValue(Dictionary<string, object?> item, string key)
{
if (!item.TryGetValue(key, out var value) || value == null)
{
return null;
}
if (value is JsonElement jsonElement)
{
if (jsonElement.ValueKind == JsonValueKind.Number && jsonElement.TryGetInt32(out var intValue))
{
return intValue;
}
if (jsonElement.ValueKind == JsonValueKind.String &&
int.TryParse(jsonElement.GetString(), out var parsedInt))
{
return parsedInt;
}
return null;
}
return int.TryParse(value.ToString(), out var parsed) ? parsed : null;
}
/// <summary>
/// Merges two source queues without reordering either queue.
/// At each step, compare only the current head from each source and dequeue the winner.
@@ -57,8 +57,18 @@ public partial class JellyfinController
private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName,
string playlistId)
{
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
var playlistConfig = SpotifyPlaylistScopeResolver.ResolveConfig(
_spotifySettings,
spotifyPlaylistName,
userId,
playlistId);
var playlistScopeUserId = SpotifyPlaylistScopeResolver.GetUserId(playlistConfig, userId);
var playlistScopeId = SpotifyPlaylistScopeResolver.GetScopeId(playlistConfig, 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 +76,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 +123,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,14 +160,63 @@ 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)
{
_logger.LogInformation("No ordered matched tracks in cache for {Playlist}, checking if we can fetch",
_logger.LogInformation(
"No ordered matched tracks in cache for {Playlist}; attempting exact-scope rebuild before fallback",
spotifyPlaylistName);
return null; // Fall back to legacy mode
if (_spotifyTrackMatchingService != null)
{
try
{
await _spotifyTrackMatchingService.TriggerRebuildForPlaylistAsync(
spotifyPlaylistName,
playlistScopeUserId,
playlistScopeId);
orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"On-demand rebuild failed for {Playlist}; falling back to cached compatibility paths",
spotifyPlaylistName);
}
}
if (orderedTracks == null || orderedTracks.Count == 0)
{
var legacyCacheKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
spotifyPlaylistName,
playlistScopeUserId,
playlistScopeId);
var legacySongs = await _cache.GetAsync<List<Song>>(legacyCacheKey);
if (legacySongs != null && legacySongs.Count > 0)
{
orderedTracks = legacySongs.Select((song, index) => new MatchedTrack
{
Position = index,
MatchedSong = song
}).ToList();
_logger.LogInformation(
"Loaded {Count} legacy matched tracks for {Playlist} after ordered cache miss",
orderedTracks.Count,
spotifyPlaylistName);
}
}
if (orderedTracks == null || orderedTracks.Count == 0)
{
_logger.LogInformation("Ordered matched tracks are still unavailable for {Playlist}", spotifyPlaylistName);
return null; // Fall back to legacy mode
}
}
_logger.LogDebug("Using {Count} ordered matched tracks for {Playlist}",
@@ -162,11 +224,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 +298,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 +455,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 +977,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 +1019,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 +1049,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))
+101 -7
View File
@@ -34,15 +34,18 @@ public partial class JellyfinController : ControllerBase
private readonly SpotifyApiSettings _spotifyApiSettings;
private readonly ScrobblingSettings _scrobblingSettings;
private readonly IMusicMetadataService _metadataService;
private readonly ExternalArtistAppearancesService _externalArtistAppearancesService;
private readonly ParallelMetadataService? _parallelMetadataService;
private readonly ILocalLibraryService _localLibraryService;
private readonly IDownloadService _downloadService;
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;
private readonly SpotifyTrackMatchingService? _spotifyTrackMatchingService;
private readonly SpotifyLyricsService? _spotifyLyricsService;
private readonly LyricsPlusService? _lyricsPlusService;
private readonly LrclibService? _lrclibService;
@@ -60,11 +63,13 @@ public partial class JellyfinController : ControllerBase
IOptions<SpotifyApiSettings> spotifyApiSettings,
IOptions<ScrobblingSettings> scrobblingSettings,
IMusicMetadataService metadataService,
ExternalArtistAppearancesService externalArtistAppearancesService,
ILocalLibraryService localLibraryService,
IDownloadService downloadService,
JellyfinResponseBuilder responseBuilder,
JellyfinModelMapper modelMapper,
JellyfinProxyService proxyService,
JellyfinUserContextResolver jellyfinUserContextResolver,
JellyfinSessionManager sessionManager,
OdesliService odesliService,
RedisCacheService cache,
@@ -73,6 +78,7 @@ public partial class JellyfinController : ControllerBase
ParallelMetadataService? parallelMetadataService = null,
PlaylistSyncService? playlistSyncService = null,
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
SpotifyTrackMatchingService? spotifyTrackMatchingService = null,
SpotifyLyricsService? spotifyLyricsService = null,
LyricsPlusService? lyricsPlusService = null,
LrclibService? lrclibService = null,
@@ -85,15 +91,18 @@ public partial class JellyfinController : ControllerBase
_spotifyApiSettings = spotifyApiSettings.Value;
_scrobblingSettings = scrobblingSettings.Value;
_metadataService = metadataService;
_externalArtistAppearancesService = externalArtistAppearancesService;
_parallelMetadataService = parallelMetadataService;
_localLibraryService = localLibraryService;
_downloadService = downloadService;
_responseBuilder = responseBuilder;
_modelMapper = modelMapper;
_proxyService = proxyService;
_jellyfinUserContextResolver = jellyfinUserContextResolver;
_sessionManager = sessionManager;
_playlistSyncService = playlistSyncService;
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
_spotifyTrackMatchingService = spotifyTrackMatchingService;
_spotifyLyricsService = spotifyLyricsService;
_lyricsPlusService = lyricsPlusService;
_lrclibService = lrclibService;
@@ -290,6 +299,75 @@ public partial class JellyfinController : ControllerBase
return _responseBuilder.CreateItemsResponse(new List<Song>());
}
/// <summary>
/// Gets "appears on" albums for an external artist when Jellyfin requests
/// ContributingArtistIds for album containers.
/// </summary>
private async Task<IActionResult> GetExternalContributorChildItems(string provider, string type, string externalId, string? includeItemTypes, CancellationToken cancellationToken = default)
{
if (IsFavoritesOnlyRequest())
{
_logger.LogDebug(
"Suppressing external contributing artist items for favorites-only request: provider={Provider}, type={Type}, externalId={ExternalId}",
provider,
type,
externalId);
return CreateEmptyItemsResponse(GetRequestedStartIndex());
}
if (!string.Equals(type, "artist", StringComparison.OrdinalIgnoreCase))
{
_logger.LogDebug(
"Ignoring external contributing item request for non-artist id: provider={Provider}, type={Type}, externalId={ExternalId}",
provider,
type,
externalId);
return CreateEmptyItemsResponse(GetRequestedStartIndex());
}
var itemTypes = ParseItemTypes(includeItemTypes);
var itemTypesUnspecified = itemTypes == null || itemTypes.Length == 0;
var wantsAlbums = itemTypesUnspecified || itemTypes!.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase);
if (!wantsAlbums)
{
_logger.LogDebug(
"No external contributing artist handler for requested item types {ItemTypes}",
string.Join(",", itemTypes ?? Array.Empty<string>()));
return CreateEmptyItemsResponse(GetRequestedStartIndex());
}
var albums = await _externalArtistAppearancesService.GetAppearsOnAlbumsAsync(provider, externalId, cancellationToken);
var items = albums
.Select(_responseBuilder.ConvertAlbumToJellyfinItem)
.ToList();
items = ApplyRequestedAlbumOrderingIfApplicable(
items,
itemTypes,
Request.Query["SortBy"].ToString(),
Request.Query["SortOrder"].ToString());
var totalRecordCount = items.Count;
var startIndex = GetRequestedStartIndex();
if (startIndex > 0)
{
items = items.Skip(startIndex).ToList();
}
if (int.TryParse(Request.Query["Limit"], out var parsedLimit) && parsedLimit > 0)
{
items = items.Take(parsedLimit).ToList();
}
return _responseBuilder.CreateJsonResponse(new
{
Items = items,
TotalRecordCount = totalRecordCount,
StartIndex = startIndex
});
}
private int GetRequestedStartIndex()
{
return int.TryParse(Request.Query["StartIndex"], out var startIndex) && startIndex > 0
@@ -678,7 +756,7 @@ public partial class JellyfinController : ControllerBase
if (fallbackBytes != null && fallbackContentType != null)
{
return File(fallbackBytes, fallbackContentType);
return CreateConditionalImageResponse(fallbackBytes, fallbackContentType);
}
}
}
@@ -687,7 +765,7 @@ public partial class JellyfinController : ControllerBase
return await GetPlaceholderImageAsync();
}
return File(imageBytes, contentType);
return CreateConditionalImageResponse(imageBytes, contentType);
}
// Check Redis cache for previously fetched external image
@@ -696,7 +774,7 @@ public partial class JellyfinController : ControllerBase
if (cachedImageBytes != null)
{
_logger.LogDebug("Cache hit for external {Type} image: {Provider}/{ExternalId}", type, provider, externalId);
return File(cachedImageBytes, "image/jpeg");
return CreateConditionalImageResponse(cachedImageBytes, "image/jpeg");
}
// Get external cover art URL
@@ -767,7 +845,7 @@ public partial class JellyfinController : ControllerBase
_logger.LogDebug("Successfully fetched and cached external image from host {Host}, size: {Size} bytes",
safeCoverUri.Host, imageBytes.Length);
return File(imageBytes, "image/jpeg");
return CreateConditionalImageResponse(imageBytes, "image/jpeg");
}
catch (Exception ex)
{
@@ -789,7 +867,7 @@ public partial class JellyfinController : ControllerBase
if (System.IO.File.Exists(placeholderPath))
{
var imageBytes = await System.IO.File.ReadAllBytesAsync(placeholderPath);
return File(imageBytes, "image/png");
return CreateConditionalImageResponse(imageBytes, "image/png");
}
// Fallback: Return a 1x1 transparent PNG as minimal placeholder
@@ -797,7 +875,20 @@ public partial class JellyfinController : ControllerBase
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
);
return File(transparentPng, "image/png");
return CreateConditionalImageResponse(transparentPng, "image/png");
}
private IActionResult CreateConditionalImageResponse(byte[] imageBytes, string contentType)
{
var etag = ImageConditionalRequestHelper.ComputeStrongETag(imageBytes);
Response.Headers["ETag"] = etag;
if (ImageConditionalRequestHelper.MatchesIfNoneMatch(Request.Headers, etag))
{
return StatusCode(StatusCodes.Status304NotModified);
}
return File(imageBytes, contentType);
}
private async Task<string?> ResolveCurrentSpotifyPlaylistImageTagAsync(string itemId, string imageType)
@@ -1735,7 +1826,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)
+26 -3
View File
@@ -79,6 +79,29 @@ public class TrackMetadataRequest
public int? DurationMs { get; set; }
}
/// <summary>
/// Request model for updating configuration
/// </summary>
public class SquidWtfEndpointHealthResponse
{
public DateTime TestedAtUtc { get; set; }
public int TotalRows { get; set; }
public List<SquidWtfEndpointHealthRow> Endpoints { get; set; } = new();
}
public class SquidWtfEndpointHealthRow
{
public string Host { get; set; } = string.Empty;
public string? ApiUrl { get; set; }
public string? StreamingUrl { get; set; }
public SquidWtfEndpointProbeResult Api { get; set; } = new();
public SquidWtfEndpointProbeResult Streaming { get; set; } = new();
}
public class SquidWtfEndpointProbeResult
{
public bool Configured { get; set; }
public bool IsUp { get; set; }
public string State { get; set; } = "unknown";
public int? StatusCode { get; set; }
public long? LatencyMs { get; set; }
public string? RequestUrl { get; set; }
public string? Error { get; set; }
}
@@ -0,0 +1,37 @@
namespace allstarr.Models.Jellyfin;
/// <summary>
/// Canonical Jellyfin Items response wrapper used by search-related hot paths.
/// </summary>
public class JellyfinItemsResponse
{
public List<Dictionary<string, object?>> Items { get; set; } = [];
public int TotalRecordCount { get; set; }
public int StartIndex { get; set; }
}
/// <summary>
/// Playback payload forwarded to Jellyfin for start/progress/stop events.
/// Nullable members are omitted to preserve the lean request shapes clients expect.
/// </summary>
public class JellyfinPlaybackStatePayload
{
public string ItemId { get; set; } = string.Empty;
public long PositionTicks { get; set; }
public bool? CanSeek { get; set; }
public bool? IsPaused { get; set; }
public bool? IsMuted { get; set; }
public string? PlayMethod { get; set; }
}
/// <summary>
/// Synthetic capabilities payload used when allstarr needs to establish a Jellyfin session.
/// </summary>
public class JellyfinSessionCapabilitiesPayload
{
public string[] PlayableMediaTypes { get; set; } = [];
public string[] SupportedCommands { get; set; } = [];
public bool SupportsMediaControl { get; set; }
public bool SupportsPersistentIdentifier { get; set; }
public bool SupportsSync { get; set; }
}
@@ -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.
+22
View File
@@ -176,6 +176,25 @@ builder.Services.ConfigureAll<HttpClientFactoryOptions>(options =>
// but we want to reduce noise in production logs
options.SuppressHandlerScope = true;
});
// Register a dedicated named HttpClient for Jellyfin backend with connection pooling.
// SocketsHttpHandler reuses TCP connections across the scoped JellyfinProxyService
// instances, eliminating per-request TCP/TLS handshake overhead.
builder.Services.AddHttpClient(JellyfinProxyService.HttpClientName)
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
// Keep up to 20 idle connections to Jellyfin alive at any time
MaxConnectionsPerServer = 20,
// Recycle pooled connections every 5 minutes to pick up DNS changes
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
// Close idle connections after 90 seconds to avoid stale sockets
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90),
// Allow HTTP/2 multiplexing when Jellyfin supports it
EnableMultipleHttp2Connections = true,
// Follow redirects within Jellyfin
AllowAutoRedirect = true,
MaxAutomaticRedirections = 5
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddHttpContextAccessor();
@@ -509,6 +528,7 @@ else
// Business services - shared across backends
builder.Services.AddSingleton(squidWtfEndpointCatalog);
builder.Services.AddMemoryCache(); // L1 in-memory tier for RedisCacheService
builder.Services.AddSingleton<RedisCacheService>();
builder.Services.AddSingleton<FavoritesMigrationService>();
builder.Services.AddSingleton<OdesliService>();
@@ -521,6 +541,8 @@ if (backendType == BackendType.Jellyfin)
// Jellyfin services
builder.Services.AddSingleton<JellyfinResponseBuilder>();
builder.Services.AddSingleton<JellyfinModelMapper>();
builder.Services.AddSingleton<ExternalArtistAppearancesService>();
builder.Services.AddScoped<JellyfinUserContextResolver>();
builder.Services.AddScoped<JellyfinProxyService>();
builder.Services.AddSingleton<JellyfinSessionManager>();
builder.Services.AddScoped<JellyfinAuthFilter>();
@@ -0,0 +1,67 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using allstarr.Models.Domain;
using allstarr.Models.Jellyfin;
using allstarr.Models.Lyrics;
using allstarr.Models.Search;
using allstarr.Models.Spotify;
namespace allstarr.Serialization;
/// <summary>
/// System.Text.Json source-generated serializer context for hot-path types.
/// Eliminates runtime reflection for serialize/deserialize operations, providing
/// 3-8x faster throughput and significantly reduced GC allocations.
///
/// Used by RedisCacheService (all cached types), search response serialization,
/// and playback session payload construction.
/// </summary>
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.Unspecified,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
)]
// Domain models (hot: cached in Redis, serialized in search responses)
[JsonSerializable(typeof(Song))]
[JsonSerializable(typeof(Album))]
[JsonSerializable(typeof(Artist))]
[JsonSerializable(typeof(SearchResult))]
[JsonSerializable(typeof(JellyfinItemsResponse))]
[JsonSerializable(typeof(JellyfinPlaybackStatePayload))]
[JsonSerializable(typeof(JellyfinSessionCapabilitiesPayload))]
[JsonSerializable(typeof(List<Song>))]
[JsonSerializable(typeof(List<Album>))]
[JsonSerializable(typeof(List<Artist>))]
// Spotify models (hot: playlist loading, track matching)
[JsonSerializable(typeof(SpotifyPlaylistTrack))]
[JsonSerializable(typeof(SpotifyPlaylist))]
[JsonSerializable(typeof(MatchedTrack))]
[JsonSerializable(typeof(MissingTrack))]
[JsonSerializable(typeof(SpotifyTrackMapping))]
[JsonSerializable(typeof(TrackMetadata))]
[JsonSerializable(typeof(List<SpotifyPlaylistTrack>))]
[JsonSerializable(typeof(List<MatchedTrack>))]
[JsonSerializable(typeof(List<MissingTrack>))]
// Lyrics models (moderate: cached in Redis)
[JsonSerializable(typeof(LyricsInfo))]
// Collection types used in cache and playlist items
[JsonSerializable(typeof(List<Dictionary<string, object?>>))]
[JsonSerializable(typeof(Dictionary<string, object?>))]
[JsonSerializable(typeof(string[]))]
[JsonSerializable(typeof(List<string>))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(byte[]))]
internal partial class AllstarrJsonContext : JsonSerializerContext
{
/// <summary>
/// Shared default instance. Use this for all hot-path serialization
/// where PropertyNamingPolicy = null (PascalCase / preserve casing).
/// </summary>
public static AllstarrJsonContext Shared { get; } = new(new JsonSerializerOptions
{
PropertyNamingPolicy = null,
DictionaryKeyPolicy = null,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
});
}
@@ -0,0 +1,47 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace allstarr.Serialization;
internal static class AllstarrJsonSerializer
{
private static readonly JsonSerializerOptions ReflectionFallbackOptions = new()
{
PropertyNamingPolicy = null,
DictionaryKeyPolicy = null,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
public static string Serialize<T>(T value)
{
var typeInfo = GetTypeInfo<T>();
if (typeInfo != null)
{
try
{
return JsonSerializer.Serialize(value, typeInfo);
}
catch (NotSupportedException)
{
// Mixed Jellyfin payloads often carry runtime-only shapes such as JsonElement,
// List<object>, or dictionary arrays. Fall back to reflection for those cases.
}
}
return JsonSerializer.Serialize(value, ReflectionFallbackOptions);
}
private static JsonTypeInfo<T>? GetTypeInfo<T>()
{
try
{
return (JsonTypeInfo<T>?)AllstarrJsonContext.Shared.GetTypeInfo(typeof(T));
}
catch
{
return null;
}
}
}
+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)
@@ -0,0 +1,39 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Http;
namespace allstarr.Services.Common;
public static class ImageConditionalRequestHelper
{
public static string ComputeStrongETag(byte[] payload)
{
var hash = SHA256.HashData(payload);
return $"\"{Convert.ToHexString(hash)}\"";
}
public static bool MatchesIfNoneMatch(IHeaderDictionary headers, string etag)
{
if (!headers.TryGetValue("If-None-Match", out var headerValues))
{
return false;
}
foreach (var headerValue in headerValues)
{
if (string.IsNullOrEmpty(headerValue))
{
continue;
}
foreach (var candidate in headerValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (candidate == "*" || string.Equals(candidate, etag, StringComparison.Ordinal))
{
return true;
}
}
}
return false;
}
}
+278 -26
View File
@@ -1,27 +1,57 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Serialization;
using StackExchange.Redis;
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using System.Text.RegularExpressions;
namespace allstarr.Services.Common;
/// <summary>
/// Redis caching service for metadata and images.
/// Tiered caching service: L1 in-memory (IMemoryCache, ~30s TTL) backed by
/// L2 Redis for persistence. The memory tier eliminates Redis network round-trips
/// for repeated reads within a short window (playlist scrolling, search-as-you-type).
/// </summary>
public class RedisCacheService
{
/// <summary>
/// Default L1 memory cache duration. Kept short to avoid serving stale data,
/// but long enough to absorb bursts of repeated reads.
/// </summary>
private static readonly TimeSpan DefaultMemoryTtl = TimeSpan.FromSeconds(30);
/// <summary>
/// Key prefixes that should NOT be cached in memory (e.g., large binary blobs).
/// </summary>
private static readonly string[] MemoryExcludedPrefixes = ["image:"];
private static readonly JsonSerializerOptions ReflectionFallbackJsonOptions = new()
{
PropertyNamingPolicy = null,
DictionaryKeyPolicy = null,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
private readonly RedisSettings _settings;
private readonly ILogger<RedisCacheService> _logger;
private readonly IMemoryCache _memoryCache;
private readonly ConcurrentDictionary<string, byte> _memoryKeys = new(StringComparer.Ordinal);
private IConnectionMultiplexer? _redis;
private IDatabase? _db;
private readonly object _lock = new();
public RedisCacheService(
IOptions<RedisSettings> settings,
ILogger<RedisCacheService> logger)
ILogger<RedisCacheService> logger,
IMemoryCache memoryCache)
{
_settings = settings.Value;
_logger = logger;
_memoryCache = memoryCache;
if (_settings.Enabled)
{
@@ -48,23 +78,145 @@ public class RedisCacheService
public bool IsEnabled => _settings.Enabled && _db != null;
/// <summary>
/// Gets a cached value as a string.
/// Checks whether a key should be cached in the L1 memory tier.
/// Large binary data (images) is excluded to avoid memory pressure.
/// </summary>
public async Task<string?> GetStringAsync(string key)
private static bool ShouldUseMemoryCache(string key)
{
if (!IsEnabled) return null;
foreach (var prefix in MemoryExcludedPrefixes)
{
if (key.StartsWith(prefix, StringComparison.Ordinal))
return false;
}
return true;
}
/// <summary>
/// Computes the L1 TTL for a key mirrored from Redis.
/// Returns null for already-expired entries, which skips L1 caching entirely.
/// </summary>
private static TimeSpan? GetMemoryTtl(TimeSpan? redisExpiry)
{
if (redisExpiry == null)
return DefaultMemoryTtl;
if (redisExpiry.Value <= TimeSpan.Zero)
return null;
return redisExpiry.Value < DefaultMemoryTtl ? redisExpiry.Value : DefaultMemoryTtl;
}
private bool TryGetMemoryValue(string key, out string? value)
{
if (!ShouldUseMemoryCache(key))
{
value = null;
return false;
}
return _memoryCache.TryGetValue(key, out value);
}
private void SetMemoryValue(string key, string value, TimeSpan? expiry)
{
if (!ShouldUseMemoryCache(key))
return;
var memoryTtl = GetMemoryTtl(expiry);
if (memoryTtl == null)
{
_memoryCache.Remove(key);
_memoryKeys.TryRemove(key, out _);
return;
}
var options = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = memoryTtl
};
options.RegisterPostEvictionCallback(
static (cacheKey, _, _, state) =>
{
if (cacheKey is string stringKey && state is ConcurrentDictionary<string, byte> memoryKeys)
{
memoryKeys.TryRemove(stringKey, out _);
}
},
_memoryKeys);
_memoryCache.Set(key, value, options);
_memoryKeys[key] = 0;
}
private int RemoveMemoryKeysByPattern(string pattern)
{
if (_memoryKeys.IsEmpty)
return 0;
if (!pattern.Contains('*') && !pattern.Contains('?'))
{
var removed = _memoryKeys.TryRemove(pattern, out _);
_memoryCache.Remove(pattern);
return removed ? 1 : 0;
}
var regex = new Regex(
"^" + Regex.Escape(pattern).Replace("\\*", ".*").Replace("\\?", ".") + "$",
RegexOptions.CultureInvariant);
var keysToRemove = _memoryKeys.Keys.Where(key => regex.IsMatch(key)).ToArray();
foreach (var key in keysToRemove)
{
_memoryCache.Remove(key);
_memoryKeys.TryRemove(key, out _);
}
return keysToRemove.Length;
}
/// <summary>
/// Gets a cached value as a string.
/// Checks L1 memory cache first, falls back to L2 Redis.
/// </summary>
public ValueTask<string?> GetStringAsync(string key)
{
// L1: Try in-memory cache first (sub-microsecond)
if (TryGetMemoryValue(key, out var memoryValue))
{
_logger.LogDebug("L1 memory cache HIT: {Key}", key);
return new ValueTask<string?>(memoryValue);
}
if (!IsEnabled) return new ValueTask<string?>((string?)null);
return new ValueTask<string?>(GetStringFromRedisAsync(key));
}
private async Task<string?> GetStringFromRedisAsync(string key)
{
try
{
// L2: Fall back to Redis
var value = await _db!.StringGetAsync(key);
if (value.HasValue)
{
_logger.LogDebug("Redis cache HIT: {Key}", key);
_logger.LogDebug("L2 Redis cache HIT: {Key}", key);
// Promote to L1 for subsequent reads
if (ShouldUseMemoryCache(key))
{
var stringValue = (string?)value;
if (stringValue != null)
{
var redisExpiry = await _db.KeyTimeToLiveAsync(key);
SetMemoryValue(key, stringValue, redisExpiry);
}
}
}
else
{
_logger.LogDebug("Redis cache MISS: {Key}", key);
_logger.LogDebug("Cache MISS: {Key}", key);
}
return value;
}
@@ -77,15 +229,17 @@ public class RedisCacheService
/// <summary>
/// Gets a cached value and deserializes it.
/// Uses source-generated serializer for registered types (3-8x faster),
/// with automatic fallback to reflection-based serialization.
/// </summary>
public async Task<T?> GetAsync<T>(string key) where T : class
public async ValueTask<T?> GetAsync<T>(string key) where T : class
{
var json = await GetStringAsync(key);
if (string.IsNullOrEmpty(json)) return null;
try
{
return JsonSerializer.Deserialize<T>(json);
return DeserializeWithFallback<T>(json, key);
}
catch (Exception ex)
{
@@ -96,11 +250,20 @@ public class RedisCacheService
/// <summary>
/// Sets a cached value with TTL.
/// Writes to both L1 memory cache and L2 Redis.
/// </summary>
public async Task<bool> SetStringAsync(string key, string value, TimeSpan? expiry = null)
public ValueTask<bool> SetStringAsync(string key, string value, TimeSpan? expiry = null)
{
if (!IsEnabled) return false;
// Always update L1 (even if Redis is down — provides degraded caching)
SetMemoryValue(key, value, expiry);
if (!IsEnabled) return new ValueTask<bool>(false);
return new ValueTask<bool>(SetStringWithRedisAsync(key, value, expiry));
}
private async Task<bool> SetStringWithRedisAsync(string key, string value, TimeSpan? expiry)
{
try
{
return await SetStringInternalAsync(key, value, expiry);
@@ -197,12 +360,14 @@ public class RedisCacheService
/// <summary>
/// Sets a cached value by serializing it with TTL.
/// Uses source-generated serializer for registered types (3-8x faster),
/// with automatic fallback to reflection-based serialization.
/// </summary>
public async Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null) where T : class
public async ValueTask<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null) where T : class
{
try
{
var json = JsonSerializer.Serialize(value);
var json = SerializeWithFallback(value, key);
return await SetStringAsync(key, json, expiry);
}
catch (Exception ex)
@@ -212,13 +377,80 @@ public class RedisCacheService
}
}
/// <summary>
/// Deletes a cached value.
/// </summary>
public async Task<bool> DeleteAsync(string key)
private T? DeserializeWithFallback<T>(string json, string key) where T : class
{
if (!IsEnabled) return false;
var typeInfo = TryGetTypeInfo<T>();
if (typeInfo != null)
{
try
{
return JsonSerializer.Deserialize(json, typeInfo);
}
catch (NotSupportedException ex)
{
_logger.LogDebug(
ex,
"Source-generated deserialization unsupported for key: {Key}; falling back to reflection.",
key);
}
}
return JsonSerializer.Deserialize<T>(json, ReflectionFallbackJsonOptions);
}
private string SerializeWithFallback<T>(T value, string key) where T : class
{
var typeInfo = TryGetTypeInfo<T>();
if (typeInfo != null)
{
try
{
return JsonSerializer.Serialize(value, typeInfo);
}
catch (NotSupportedException ex)
{
_logger.LogDebug(
ex,
"Source-generated serialization unsupported for key: {Key}; falling back to reflection.",
key);
}
}
return JsonSerializer.Serialize(value, ReflectionFallbackJsonOptions);
}
/// <summary>
/// Attempts to resolve a JsonTypeInfo from the AllstarrJsonContext source generator.
/// Returns null if the type isn't registered, triggering fallback to reflection.
/// </summary>
private static JsonTypeInfo<T>? TryGetTypeInfo<T>() where T : class
{
try
{
return (JsonTypeInfo<T>?)AllstarrJsonContext.Default.GetTypeInfo(typeof(T));
}
catch
{
return null;
}
}
/// <summary>
/// Deletes a cached value from both L1 memory and L2 Redis.
/// </summary>
public ValueTask<bool> DeleteAsync(string key)
{
// Always evict from L1
_memoryCache.Remove(key);
_memoryKeys.TryRemove(key, out _);
if (!IsEnabled) return new ValueTask<bool>(false);
return new ValueTask<bool>(DeleteFromRedisAsync(key));
}
private async Task<bool> DeleteFromRedisAsync(string key)
{
try
{
return await _db!.KeyDeleteAsync(key);
@@ -233,10 +465,20 @@ public class RedisCacheService
/// <summary>
/// Checks if a key exists.
/// </summary>
public async Task<bool> ExistsAsync(string key)
public ValueTask<bool> ExistsAsync(string key)
{
if (!IsEnabled) return false;
if (ShouldUseMemoryCache(key) && _memoryCache.TryGetValue(key, out _))
{
return new ValueTask<bool>(true);
}
if (!IsEnabled) return new ValueTask<bool>(false);
return new ValueTask<bool>(ExistsInRedisAsync(key));
}
private async Task<bool> ExistsInRedisAsync(string key)
{
try
{
return await _db!.KeyExistsAsync(key);
@@ -271,10 +513,16 @@ public class RedisCacheService
/// Deletes all keys matching a pattern (e.g., "search:*").
/// WARNING: Use with caution as this scans all keys.
/// </summary>
public async Task<int> DeleteByPatternAsync(string pattern)
public ValueTask<int> DeleteByPatternAsync(string pattern)
{
if (!IsEnabled) return 0;
var memoryDeleted = RemoveMemoryKeysByPattern(pattern);
if (!IsEnabled) return new ValueTask<int>(memoryDeleted);
return new ValueTask<int>(DeleteByPatternFromRedisAsync(pattern, memoryDeleted));
}
private async Task<int> DeleteByPatternFromRedisAsync(string pattern, int memoryDeleted)
{
try
{
var server = _redis!.GetServer(_redis.GetEndPoints().First());
@@ -282,18 +530,22 @@ public class RedisCacheService
if (keys.Length == 0)
{
_logger.LogDebug("No keys found matching pattern: {Pattern}", pattern);
return 0;
_logger.LogDebug("No Redis keys found matching pattern: {Pattern}", pattern);
return memoryDeleted;
}
var deleted = await _db!.KeyDeleteAsync(keys);
_logger.LogDebug("Deleted {Count} Redis keys matching pattern: {Pattern}", deleted, pattern);
_logger.LogDebug(
"Deleted {RedisCount} Redis keys and {MemoryCount} memory keys matching pattern: {Pattern}",
deleted,
memoryDeleted,
pattern);
return (int)deleted;
}
catch (Exception ex)
{
_logger.LogError(ex, "Redis DELETE BY PATTERN failed for pattern: {Pattern}", pattern);
return 0;
return memoryDeleted;
}
}
}
@@ -442,16 +442,18 @@ public class RoundRobinFallbackHelper
private void LogEndpointFailure(string baseUrl, Exception ex, bool willRetry)
{
var message = BuildFailureSummary(ex);
var isTimeoutOrCancellation = ex is TaskCanceledException or OperationCanceledException;
var verb = isTimeoutOrCancellation ? "request timed out" : "request failed";
if (willRetry)
{
_logger.LogWarning("{Service} request failed at {Endpoint}: {Error}. Trying next...",
_serviceName, baseUrl, message);
_logger.LogWarning("{Service} {Verb} at {Endpoint}: {Error}. Trying next...",
_serviceName, verb, baseUrl, message);
}
else
{
_logger.LogError("{Service} request failed at {Endpoint}: {Error}",
_serviceName, baseUrl, message);
_logger.LogError("{Service} {Verb} at {Endpoint}: {Error}",
_serviceName, verb, baseUrl, message);
}
_logger.LogDebug(ex, "{Service} detailed failure for endpoint {Endpoint}",
@@ -466,6 +468,16 @@ public class RoundRobinFallbackHelper
return $"{statusCode}: {httpRequestException.StatusCode.Value}";
}
if (ex is TaskCanceledException)
{
return "Timed out";
}
if (ex is OperationCanceledException)
{
return "Canceled";
}
return ex.Message;
}
@@ -0,0 +1,57 @@
using allstarr.Models.Settings;
namespace allstarr.Services.Common;
public static class SpotifyPlaylistScopeResolver
{
public static SpotifyPlaylistConfig? ResolveConfig(
SpotifyImportSettings settings,
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null)
{
if (!string.IsNullOrWhiteSpace(jellyfinPlaylistId))
{
var byJellyfinId = settings.GetPlaylistByJellyfinId(jellyfinPlaylistId.Trim());
if (byJellyfinId != null)
{
return byJellyfinId;
}
}
return settings.GetPlaylistByName(playlistName, userId, jellyfinPlaylistId);
}
public static string? GetUserId(SpotifyPlaylistConfig? playlist, string? fallbackUserId = null)
{
if (!string.IsNullOrWhiteSpace(playlist?.UserId))
{
return playlist.UserId.Trim();
}
// A configured playlist with no explicit owner is global. Do not
// accidentally scope its caches to whichever Jellyfin user made
// the current request.
if (playlist != null)
{
return null;
}
return string.IsNullOrWhiteSpace(fallbackUserId) ? null : fallbackUserId.Trim();
}
public static string? GetScopeId(SpotifyPlaylistConfig? playlist, string? fallbackScopeId = null)
{
if (!string.IsNullOrWhiteSpace(playlist?.JellyfinId))
{
return playlist.JellyfinId.Trim();
}
if (!string.IsNullOrWhiteSpace(playlist?.Id))
{
return playlist.Id.Trim();
}
return string.IsNullOrWhiteSpace(fallbackScopeId) ? null : fallbackScopeId.Trim();
}
}
@@ -135,10 +135,15 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
{
// Execute searches in parallel
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken);
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken);
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken);
var songsTask = songLimit > 0
? SearchSongsAsync(query, songLimit, cancellationToken)
: Task.FromResult(new List<Song>());
var albumsTask = albumLimit > 0
? SearchAlbumsAsync(query, albumLimit, cancellationToken)
: Task.FromResult(new List<Album>());
var artistsTask = artistLimit > 0
? SearchArtistsAsync(query, artistLimit, cancellationToken)
: Task.FromResult(new List<Artist>());
await Task.WhenAll(songsTask, albumsTask, artistsTask);
@@ -0,0 +1,165 @@
using allstarr.Models.Domain;
namespace allstarr.Services.Jellyfin;
/// <summary>
/// Resolves "Appears on" albums for external artists.
/// Prefers provider-supplied album lists when they expose non-primary releases and
/// falls back to deriving albums from artist track payloads when needed.
/// </summary>
public class ExternalArtistAppearancesService
{
private readonly IMusicMetadataService _metadataService;
private readonly ILogger<ExternalArtistAppearancesService> _logger;
public ExternalArtistAppearancesService(
IMusicMetadataService metadataService,
ILogger<ExternalArtistAppearancesService> logger)
{
_metadataService = metadataService;
_logger = logger;
}
public async Task<List<Album>> GetAppearsOnAlbumsAsync(
string provider,
string externalId,
CancellationToken cancellationToken = default)
{
var artistTask = _metadataService.GetArtistAsync(provider, externalId, cancellationToken);
var albumsTask = _metadataService.GetArtistAlbumsAsync(provider, externalId, cancellationToken);
var tracksTask = _metadataService.GetArtistTracksAsync(provider, externalId, cancellationToken);
await Task.WhenAll(artistTask, albumsTask, tracksTask);
var artist = await artistTask;
if (artist == null || string.IsNullOrWhiteSpace(artist.Name))
{
_logger.LogDebug(
"No external artist metadata available for appears-on lookup: provider={Provider}, externalId={ExternalId}",
provider,
externalId);
return new List<Album>();
}
var allArtistAlbums = await albumsTask;
var artistTracks = await tracksTask;
var appearsOnAlbums = new Dictionary<string, Album>(StringComparer.OrdinalIgnoreCase);
var albumsById = allArtistAlbums
.Where(album => !string.IsNullOrWhiteSpace(album.Id))
.GroupBy(album => album.Id, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
foreach (var album in allArtistAlbums)
{
if (!IsKnownNonPrimaryAlbum(album, artist))
{
continue;
}
AddAlbumIfMissing(appearsOnAlbums, album);
}
foreach (var track in artistTracks)
{
var album = TryCreateAlbumFromTrack(provider, track, artist, albumsById);
if (album == null)
{
continue;
}
AddAlbumIfMissing(appearsOnAlbums, album);
}
var resolvedAlbums = appearsOnAlbums.Values
.OrderByDescending(album => album.Year ?? int.MinValue)
.ThenBy(album => album.Title, StringComparer.OrdinalIgnoreCase)
.ToList();
_logger.LogDebug(
"Resolved {Count} external appears-on albums for artist {ArtistId}",
resolvedAlbums.Count,
artist.Id);
return resolvedAlbums;
}
private static Album? TryCreateAlbumFromTrack(
string provider,
Song track,
Artist artist,
IReadOnlyDictionary<string, Album> albumsById)
{
if (string.IsNullOrWhiteSpace(track.AlbumId) || string.IsNullOrWhiteSpace(track.Album))
{
return null;
}
if (albumsById.TryGetValue(track.AlbumId, out var knownAlbum))
{
return IsKnownNonPrimaryAlbum(knownAlbum, artist) ? knownAlbum : null;
}
if (string.IsNullOrWhiteSpace(track.AlbumArtist) || NamesEqual(track.AlbumArtist, artist.Name))
{
return null;
}
return new Album
{
Id = track.AlbumId,
Title = track.Album,
Artist = track.AlbumArtist,
Year = track.Year,
SongCount = track.TotalTracks,
CoverArtUrl = track.CoverArtUrl,
IsLocal = false,
ExternalProvider = provider,
ExternalId = ExtractExternalAlbumId(track.AlbumId, provider)
};
}
private static bool IsKnownNonPrimaryAlbum(Album album, Artist artist)
{
if (!string.IsNullOrWhiteSpace(album.ArtistId) && !string.IsNullOrWhiteSpace(artist.Id))
{
return !string.Equals(album.ArtistId, artist.Id, StringComparison.OrdinalIgnoreCase);
}
return !string.IsNullOrWhiteSpace(album.Artist) && !NamesEqual(album.Artist, artist.Name);
}
private static void AddAlbumIfMissing(IDictionary<string, Album> albums, Album album)
{
var key = BuildAlbumKey(album);
if (!albums.ContainsKey(key))
{
albums[key] = album;
}
}
private static string BuildAlbumKey(Album album)
{
if (!string.IsNullOrWhiteSpace(album.Id))
{
return album.Id;
}
return $"{album.Title}\u001f{album.Artist}";
}
private static string? ExtractExternalAlbumId(string albumId, string provider)
{
var prefix = $"ext-{provider}-album-";
return albumId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
? albumId[prefix.Length..]
: null;
}
private static bool NamesEqual(string? left, string? right)
{
return string.Equals(
left?.Trim(),
right?.Trim(),
StringComparison.OrdinalIgnoreCase);
}
}
@@ -10,12 +10,21 @@ namespace allstarr.Services.Jellyfin;
/// <summary>
/// Handles proxying requests to the Jellyfin server and authentication.
/// Uses a named HttpClient ("JellyfinBackend") with SocketsHttpHandler for
/// TCP connection pooling across scoped instances.
/// </summary>
public class JellyfinProxyService
{
/// <summary>
/// The IHttpClientFactory registration name for the Jellyfin backend client.
/// Configured with SocketsHttpHandler for connection pooling in Program.cs.
/// </summary>
public const string HttpClientName = "JellyfinBackend";
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;
@@ -28,16 +37,35 @@ public class JellyfinProxyService
IHttpClientFactory httpClientFactory,
IOptions<JellyfinSettings> settings,
IHttpContextAccessor httpContextAccessor,
JellyfinUserContextResolver userContextResolver,
ILogger<JellyfinProxyService> logger,
RedisCacheService cache)
{
_httpClient = httpClientFactory.CreateClient();
_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>
@@ -183,14 +211,10 @@ public class JellyfinProxyService
{
using var request = CreateClientGetRequest(url, clientHeaders, out var isBrowserStaticRequest, out var isPublicEndpoint);
var response = await _httpClient.SendAsync(request);
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
var statusCode = (int)response.StatusCode;
// Always parse the response, even for errors
// The caller needs to see 401s so the client can re-authenticate
var content = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
if (!isBrowserStaticRequest && !isPublicEndpoint)
@@ -199,23 +223,22 @@ public class JellyfinProxyService
}
// Try to parse error response to pass through to client
if (!string.IsNullOrWhiteSpace(content))
try
{
try
{
var errorDoc = JsonDocument.Parse(content);
return (errorDoc, statusCode);
}
catch
{
// Not valid JSON, return null
}
await using var errorStream = await response.Content.ReadAsStreamAsync();
var errorDoc = await JsonDocument.ParseAsync(errorStream);
return (errorDoc, statusCode);
}
catch (JsonException)
{
// Not valid JSON, return null
}
return (null, statusCode);
}
return (JsonDocument.Parse(content), statusCode);
await using var stream = await response.Content.ReadAsStreamAsync();
return (await JsonDocument.ParseAsync(stream), statusCode);
}
private HttpRequestMessage CreateClientGetRequest(
@@ -544,10 +567,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
@@ -594,10 +614,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))
{
@@ -639,10 +656,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);
}
@@ -661,10 +675,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))
{
@@ -691,10 +702,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 _))
@@ -885,10 +893,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)
@@ -1009,12 +1014,12 @@ public class JellyfinProxyService
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var response = await _httpClient.SendAsync(request);
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
var statusCode = (int)response.StatusCode;
var content = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Jellyfin internal request returned {StatusCode} for {Url}: {Content}",
statusCode, url, content);
return (null, statusCode);
@@ -1022,12 +1027,13 @@ public class JellyfinProxyService
try
{
var jsonDocument = JsonDocument.Parse(content);
await using var stream = await response.Content.ReadAsStreamAsync();
var jsonDocument = await JsonDocument.ParseAsync(stream);
return (jsonDocument, statusCode);
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse JSON response from {Url}: {Content}", url, content);
_logger.LogError(ex, "Failed to parse JSON response from {Url}", url);
return (null, statusCode);
}
}
@@ -1,10 +1,11 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using allstarr.Models.Jellyfin;
using allstarr.Models.Settings;
using allstarr.Serialization;
namespace allstarr.Services.Jellyfin;
@@ -185,21 +186,21 @@ public class JellyfinSessionManager : IDisposable
/// </summary>
private async Task<bool> PostCapabilitiesAsync(IHeaderDictionary headers)
{
var capabilities = new
var capabilities = new JellyfinSessionCapabilitiesPayload
{
PlayableMediaTypes = new[] { "Audio" },
SupportedCommands = new[]
{
PlayableMediaTypes = ["Audio"],
SupportedCommands =
[
"Play",
"Playstate",
"PlayNext"
},
],
SupportsMediaControl = true,
SupportsPersistentIdentifier = true,
SupportsSync = false
};
var json = JsonSerializer.Serialize(capabilities);
var json = AllstarrJsonSerializer.Serialize(capabilities);
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", json, headers);
if (statusCode == 204 || statusCode == 200)
@@ -455,12 +456,12 @@ public class JellyfinSessionManager : IDisposable
// Report playback stopped to Jellyfin if we have a playing item (for scrobbling)
if (!string.IsNullOrEmpty(session.LastPlayingItemId))
{
var stopPayload = new
var stopPayload = new JellyfinPlaybackStatePayload
{
ItemId = session.LastPlayingItemId,
PositionTicks = session.LastPlayingPositionTicks ?? 0
};
var stopJson = JsonSerializer.Serialize(stopPayload);
var stopJson = AllstarrJsonSerializer.Serialize(stopPayload);
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers);
_logger.LogInformation("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
@@ -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)}";
}
}
@@ -160,9 +160,15 @@ public class QobuzMetadataService : TrackParserBase, IMusicMetadataService
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
{
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken);
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken);
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken);
var songsTask = songLimit > 0
? SearchSongsAsync(query, songLimit, cancellationToken)
: Task.FromResult(new List<Song>());
var albumsTask = albumLimit > 0
? SearchAlbumsAsync(query, albumLimit, cancellationToken)
: Task.FromResult(new List<Album>());
var artistsTask = artistLimit > 0
? SearchArtistsAsync(query, artistLimit, cancellationToken)
: Task.FromResult(new List<Artist>());
await Task.WhenAll(songsTask, albumsTask, artistsTask);
@@ -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,20 @@ 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 = SpotifyPlaylistScopeResolver.ResolveConfig(
_spotifyImportSettings,
playlistName,
userId,
jellyfinPlaylistId);
var playlistScopeUserId = SpotifyPlaylistScopeResolver.GetUserId(playlistConfig, userId);
var playlistScopeId = SpotifyPlaylistScopeResolver.GetScopeId(playlistConfig, 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 +134,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 +160,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 +236,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
@@ -237,17 +248,30 @@ public class SpotifyPlaylistFetcher : BackgroundService
/// <summary>
/// Manual trigger to refresh a specific playlist.
/// </summary>
public async Task RefreshPlaylistAsync(string playlistName)
public async Task RefreshPlaylistAsync(
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null)
{
_logger.LogInformation("Manual refresh triggered for playlist '{Name}'", playlistName);
// Clear cache to force refresh
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName);
var playlistConfig = SpotifyPlaylistScopeResolver.ResolveConfig(
_spotifyImportSettings,
playlistName,
userId,
jellyfinPlaylistId);
var playlistScopeUserId = SpotifyPlaylistScopeResolver.GetUserId(playlistConfig, userId);
var playlistScopeId = SpotifyPlaylistScopeResolver.GetScopeId(playlistConfig, jellyfinPlaylistId);
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
await _cache.DeleteAsync(cacheKey);
// Re-fetch
await GetPlaylistTracksAsync(playlistName);
await ClearPlaylistImageCacheAsync(playlistName);
await GetPlaylistTracksAsync(playlistName, playlistScopeUserId, playlistConfig?.JellyfinId ?? jellyfinPlaylistId);
await ClearPlaylistImageCacheAsync(playlistName, userId, jellyfinPlaylistId);
}
/// <summary>
@@ -259,13 +283,20 @@ public class SpotifyPlaylistFetcher : BackgroundService
foreach (var config in _spotifyImportSettings.Playlists)
{
await RefreshPlaylistAsync(config.Name);
await RefreshPlaylistAsync(config.Name, config.UserId, config.JellyfinId);
}
}
private async Task ClearPlaylistImageCacheAsync(string playlistName)
private async Task ClearPlaylistImageCacheAsync(
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null)
{
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
var playlistConfig = SpotifyPlaylistScopeResolver.ResolveConfig(
_spotifyImportSettings,
playlistName,
userId,
jellyfinPlaylistId);
if (playlistConfig == null || string.IsNullOrWhiteSpace(playlistConfig.JellyfinId))
{
return;
@@ -331,7 +362,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
{
// Check each playlist to see if it needs refreshing based on cron schedule
var now = DateTime.UtcNow;
var needsRefresh = new List<string>();
var needsRefresh = new List<SpotifyPlaylistConfig>();
foreach (var config in _spotifyImportSettings.Playlists)
{
@@ -342,7 +373,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)
@@ -352,7 +386,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
if (nextRun.HasValue && now >= nextRun.Value)
{
needsRefresh.Add(config.Name);
needsRefresh.Add(config);
_logger.LogInformation("Playlist '{Name}' needs refresh - last fetched {Age:F1}h ago, next run was {NextRun}",
config.Name, (now - cached.FetchedAt).TotalHours, nextRun.Value);
}
@@ -360,7 +394,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
else
{
// No cache, fetch it
needsRefresh.Add(config.Name);
needsRefresh.Add(config);
}
}
catch (Exception ex)
@@ -374,24 +408,24 @@ public class SpotifyPlaylistFetcher : BackgroundService
{
_logger.LogInformation("=== CRON TRIGGER: Fetching {Count} playlists ===", needsRefresh.Count);
foreach (var playlistName in needsRefresh)
foreach (var config in needsRefresh)
{
if (stoppingToken.IsCancellationRequested) break;
try
{
await GetPlaylistTracksAsync(playlistName);
await GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
// Rate limiting between playlists
if (playlistName != needsRefresh.Last())
if (!ReferenceEquals(config, needsRefresh.Last()))
{
_logger.LogWarning("Finished fetching '{Name}' - waiting 3 seconds before next playlist to avoid rate limits...", playlistName);
_logger.LogWarning("Finished fetching '{Name}' - waiting 3 seconds before next playlist to avoid rate limits...", config.Name);
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching playlist '{Name}'", playlistName);
_logger.LogError(ex, "Error fetching playlist '{Name}'", config.Name);
}
}
@@ -419,7 +453,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,24 @@ public class SpotifyTrackMatchingService : BackgroundService
return true;
}
private static string? GetPlaylistScopeUserId(SpotifyPlaylistConfig? playlist) =>
SpotifyPlaylistScopeResolver.GetUserId(playlist);
private static string? GetPlaylistScopeId(SpotifyPlaylistConfig? playlist) =>
SpotifyPlaylistScopeResolver.GetScopeId(playlist);
private SpotifyPlaylistConfig? ResolvePlaylistConfig(
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null) =>
SpotifyPlaylistScopeResolver.ResolveConfig(_spotifySettings, playlistName, userId, jellyfinPlaylistId);
private static string BuildPlaylistRunKey(SpotifyPlaylistConfig playlist) =>
CacheKeyBuilder.BuildSpotifyPlaylistScope(
playlist.Name,
GetPlaylistScopeUserId(playlist),
GetPlaylistScopeId(playlist));
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("========================================");
@@ -121,7 +140,7 @@ public class SpotifyTrackMatchingService : BackgroundService
// Use a small grace window so we don't miss exact-minute cron runs when waking slightly late.
var now = DateTime.UtcNow;
var schedulerReference = now.AddMinutes(-1);
var nextRuns = new List<(string PlaylistName, DateTime NextRun, CronExpression Cron)>();
var nextRuns = new List<(SpotifyPlaylistConfig Playlist, DateTime NextRun, CronExpression Cron)>();
foreach (var playlist in _spotifySettings.Playlists)
{
@@ -134,7 +153,7 @@ public class SpotifyTrackMatchingService : BackgroundService
if (nextRun.HasValue)
{
nextRuns.Add((playlist.Name, nextRun.Value, cron));
nextRuns.Add((playlist, nextRun.Value, cron));
}
else
{
@@ -169,7 +188,7 @@ public class SpotifyTrackMatchingService : BackgroundService
var waitTime = nextPlaylist.NextRun - now;
_logger.LogInformation("Next scheduled run: {Playlist} at {Time} UTC (in {Minutes:F1} minutes)",
nextPlaylist.PlaylistName, nextPlaylist.NextRun, waitTime.TotalMinutes);
nextPlaylist.Playlist.Name, nextPlaylist.NextRun, waitTime.TotalMinutes);
var maxWait = TimeSpan.FromHours(1);
var actualWait = waitTime > maxWait ? maxWait : waitTime;
@@ -190,10 +209,10 @@ public class SpotifyTrackMatchingService : BackgroundService
break;
}
_logger.LogInformation("→ Running scheduled rebuild for {Playlist}", due.PlaylistName);
_logger.LogInformation("→ Running scheduled rebuild for {Playlist}", due.Playlist.Name);
var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync(
due.PlaylistName,
due.Playlist,
stoppingToken,
trigger: "cron");
@@ -204,7 +223,7 @@ public class SpotifyTrackMatchingService : BackgroundService
}
_logger.LogInformation("✓ Finished scheduled rebuild for {Playlist} - Next run at {NextRun} UTC",
due.PlaylistName, due.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
due.Playlist.Name, due.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
}
// Avoid a tight loop if one or more due playlists were skipped by cooldown.
@@ -225,29 +244,24 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Rebuilds a single playlist from scratch (clears cache, fetches fresh data, re-matches).
/// Used by individual per-playlist rebuild actions.
/// </summary>
private async Task RebuildSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
private async Task RebuildSinglePlaylistAsync(SpotifyPlaylistConfig playlist, CancellationToken cancellationToken)
{
var playlist = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
if (playlist == null)
{
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
return;
}
var playlistScopeUserId = GetPlaylistScopeUserId(playlist);
var playlistScopeId = GetPlaylistScopeId(playlist);
var playlistName = playlist.Name;
_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)
@@ -268,7 +282,7 @@ public class SpotifyTrackMatchingService : BackgroundService
if (playlistFetcher != null)
{
// Force refresh from Spotify (clears cache and re-fetches)
await playlistFetcher.RefreshPlaylistAsync(playlist.Name);
await playlistFetcher.RefreshPlaylistAsync(playlist.Name, playlistScopeUserId, playlist.JellyfinId);
}
}
@@ -280,13 +294,13 @@ public class SpotifyTrackMatchingService : BackgroundService
{
// Use new direct API mode with ISRC support
await MatchPlaylistTracksWithIsrcAsync(
playlist.Name, playlistFetcher, metadataService, cancellationToken);
playlist, playlistFetcher, metadataService, cancellationToken);
}
else
{
// Fall back to legacy mode
await MatchPlaylistTracksLegacyAsync(
playlist.Name, metadataService, cancellationToken);
playlist, metadataService, cancellationToken);
}
}
catch (Exception ex)
@@ -303,16 +317,9 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Matches tracks for a single playlist WITHOUT clearing cache or refreshing from Spotify.
/// Used for lightweight re-matching when only local library has changed.
/// </summary>
private async Task MatchSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
private async Task MatchSinglePlaylistAsync(SpotifyPlaylistConfig playlist, CancellationToken cancellationToken)
{
var playlist = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
if (playlist == null)
{
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
return;
}
var playlistName = playlist.Name;
using var scope = _serviceProvider.CreateScope();
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
@@ -330,13 +337,13 @@ public class SpotifyTrackMatchingService : BackgroundService
{
// Use new direct API mode with ISRC support
await MatchPlaylistTracksWithIsrcAsync(
playlist.Name, playlistFetcher, metadataService, cancellationToken);
playlist, playlistFetcher, metadataService, cancellationToken);
}
else
{
// Fall back to legacy mode
await MatchPlaylistTracksLegacyAsync(
playlist.Name, metadataService, cancellationToken);
playlist, metadataService, cancellationToken);
}
await ClearPlaylistImageCacheAsync(playlist);
@@ -375,17 +382,28 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Public method to trigger full rebuild for a single playlist (called from individual "Rebuild Remote" button).
/// This clears cache, fetches fresh data, and re-matches - same workflow as scheduled cron rebuilds for a playlist.
/// </summary>
public async Task TriggerRebuildForPlaylistAsync(string playlistName)
public async Task TriggerRebuildForPlaylistAsync(
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null)
{
_logger.LogInformation("Manual full rebuild triggered for playlist: {Playlist}", playlistName);
var playlist = ResolvePlaylistConfig(playlistName, userId, jellyfinPlaylistId);
if (playlist == null)
{
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
return;
}
var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync(
playlistName,
playlist,
CancellationToken.None,
trigger: "manual");
if (!rebuilt)
{
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
var runKey = BuildPlaylistRunKey(playlist);
if (_lastRunTimes.TryGetValue(runKey, out var lastRun))
{
var timeSinceLastRun = DateTime.UtcNow - lastRun;
var remaining = _minimumRunInterval - timeSinceLastRun;
@@ -399,11 +417,12 @@ public class SpotifyTrackMatchingService : BackgroundService
}
private async Task<bool> TryRunSinglePlaylistRebuildWithCooldownAsync(
string playlistName,
SpotifyPlaylistConfig playlist,
CancellationToken cancellationToken,
string trigger)
{
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
var runKey = BuildPlaylistRunKey(playlist);
if (_lastRunTimes.TryGetValue(runKey, out var lastRun))
{
var timeSinceLastRun = DateTime.UtcNow - lastRun;
if (timeSinceLastRun < _minimumRunInterval)
@@ -411,15 +430,15 @@ public class SpotifyTrackMatchingService : BackgroundService
_logger.LogWarning(
"Skipping {Trigger} rebuild for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
trigger,
playlistName,
playlist.Name,
(int)timeSinceLastRun.TotalSeconds,
(int)_minimumRunInterval.TotalSeconds);
return false;
}
}
await RebuildSinglePlaylistAsync(playlistName, cancellationToken);
_lastRunTimes[playlistName] = DateTime.UtcNow;
await RebuildSinglePlaylistAsync(playlist, cancellationToken);
_lastRunTimes[runKey] = DateTime.UtcNow;
return true;
}
@@ -439,14 +458,23 @@ public class SpotifyTrackMatchingService : BackgroundService
/// This bypasses cron schedules and runs immediately WITHOUT clearing cache or refreshing from Spotify.
/// Use this when only the local library has changed, not when Spotify playlist changed.
/// </summary>
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
public async Task TriggerMatchingForPlaylistAsync(
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null)
{
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist} (lightweight, no cache clear)", playlistName);
var playlist = ResolvePlaylistConfig(playlistName, userId, jellyfinPlaylistId);
if (playlist == null)
{
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
return;
}
// Intentionally no cooldown here: this path should react immediately to
// local library changes and manual mapping updates without waiting for
// Spotify API cooldown windows.
await MatchSinglePlaylistAsync(playlistName, CancellationToken.None);
await MatchSinglePlaylistAsync(playlist, CancellationToken.None);
}
private async Task RebuildAllPlaylistsAsync(CancellationToken cancellationToken)
@@ -466,7 +494,7 @@ public class SpotifyTrackMatchingService : BackgroundService
try
{
await RebuildSinglePlaylistAsync(playlist.Name, cancellationToken);
await RebuildSinglePlaylistAsync(playlist, cancellationToken);
}
catch (Exception ex)
{
@@ -494,7 +522,7 @@ public class SpotifyTrackMatchingService : BackgroundService
try
{
await MatchSinglePlaylistAsync(playlist.Name, cancellationToken);
await MatchSinglePlaylistAsync(playlist, cancellationToken);
}
catch (Exception ex)
{
@@ -512,15 +540,25 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Uses GREEDY ASSIGNMENT to maximize total matches.
/// </summary>
private async Task MatchPlaylistTracksWithIsrcAsync(
string playlistName,
SpotifyPlaylistConfig playlistConfig,
SpotifyPlaylistFetcher playlistFetcher,
IMusicMetadataService metadataService,
CancellationToken cancellationToken)
{
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName);
var playlist = playlistConfig ?? throw new ArgumentNullException(nameof(playlistConfig));
var playlistName = playlist.Name;
var playlistScopeUserId = GetPlaylistScopeUserId(playlist);
var playlistScopeId = GetPlaylistScopeId(playlist);
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,
playlist.JellyfinId);
if (spotifyTracks.Count == 0)
{
_logger.LogWarning("No tracks found for {Playlist}, skipping matching", playlistName);
@@ -528,12 +566,10 @@ 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();
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
if (!string.IsNullOrEmpty(playlist.JellyfinId))
{
// Get existing tracks from Jellyfin playlist to avoid re-matching
using var scope = _serviceProvider.CreateScope();
@@ -545,8 +581,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 = playlist.UserId ?? jellyfinSettings.UserId;
var jellyfinPlaylistId = playlist.JellyfinId;
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items";
var queryParams = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(userId))
{
@@ -628,10 +665,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);
@@ -659,7 +704,7 @@ public class SpotifyTrackMatchingService : BackgroundService
// PHASE 1: Get ALL Jellyfin tracks from the playlist (already injected by plugin)
var jellyfinTracks = new List<Song>();
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
if (!string.IsNullOrEmpty(playlist.JellyfinId))
{
using var scope = _serviceProvider.CreateScope();
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
@@ -670,8 +715,9 @@ public class SpotifyTrackMatchingService : BackgroundService
{
try
{
var userId = jellyfinSettings.UserId;
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
var userId = playlist.UserId ?? jellyfinSettings.UserId;
var jellyfinPlaylistId = playlist.JellyfinId;
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items";
var queryParams = new Dictionary<string, string> { ["Fields"] = CachedPlaylistItemFields };
if (!string.IsNullOrEmpty(userId))
{
@@ -942,19 +988,19 @@ 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",
playlistName, statsLocalCount, statsExternalCount, statsMissingCount);
// Calculate cache expiration: until next cron run (not just cache duration from settings)
var playlist = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
var cacheExpiration = TimeSpan.FromHours(24); // Default 24 hours
if (playlist != null && !string.IsNullOrEmpty(playlist.SyncSchedule))
if (!string.IsNullOrEmpty(playlist.SyncSchedule))
{
try
{
@@ -981,10 +1027,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);
@@ -994,7 +1043,7 @@ public class SpotifyTrackMatchingService : BackgroundService
// Pre-build playlist items cache for instant serving
// This is what makes the UI show all matched tracks at once
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cacheExpiration, cancellationToken);
await PreBuildPlaylistItemsCacheAsync(playlistName, playlist.JellyfinId, spotifyTracks, matchedTracks, cacheExpiration, cancellationToken);
}
else
{
@@ -1265,12 +1314,21 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Legacy matching mode using MissingTrack from Jellyfin plugin.
/// </summary>
private async Task MatchPlaylistTracksLegacyAsync(
string playlistName,
SpotifyPlaylistConfig playlistConfig,
IMusicMetadataService metadataService,
CancellationToken cancellationToken)
{
var missingTracksKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName);
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlistName);
var playlistName = playlistConfig.Name;
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 +1435,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 +1459,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 +1535,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 +1619,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 +1911,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 +1943,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 +1972,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 });
@@ -498,10 +498,15 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
{
// Execute searches in parallel
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken);
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken);
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken);
var songsTask = songLimit > 0
? SearchSongsAsync(query, songLimit, cancellationToken)
: Task.FromResult(new List<Song>());
var albumsTask = albumLimit > 0
? SearchAlbumsAsync(query, albumLimit, cancellationToken)
: Task.FromResult(new List<Album>());
var artistsTask = artistLimit > 0
? SearchArtistsAsync(query, artistLimit, cancellationToken)
: Task.FromResult(new List<Artist>());
await Task.WhenAll(songsTask, albumsTask, artistsTask);
+40
View File
@@ -873,6 +873,46 @@
<!-- API Analytics Tab -->
<div class="tab-content" id="tab-endpoints">
<div class="card">
<h2>
SquidWTF Endpoint Health
<div class="actions">
<button class="primary" onclick="fetchSquidWtfEndpointHealth(true)">Test Endpoints</button>
</div>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Runs a real SquidWTF API search probe and a real SquidWTF streaming manifest probe against every configured mirror.
Green means the API request worked. Blue means the streaming request worked.
</p>
<div class="endpoint-health-toolbar">
<div class="endpoint-health-legend">
<span><span class="endpoint-health-dot api up"></span> API up</span>
<span><span class="endpoint-health-dot streaming up"></span> Streaming up</span>
<span><span class="endpoint-health-dot down"></span> Down</span>
<span><span class="endpoint-health-dot unknown"></span> Not tested</span>
</div>
<div class="endpoint-health-last-tested" id="squidwtf-endpoints-tested-at">Not tested yet</div>
</div>
<div style="max-height: 520px; overflow-y: auto;">
<table class="playlist-table endpoint-health-table">
<thead>
<tr>
<th>Host</th>
<th style="width: 72px; text-align: center;">API</th>
<th style="width: 96px; text-align: center;">Streaming</th>
</tr>
</thead>
<tbody id="squidwtf-endpoints-table-body">
<tr>
<td colspan="3" class="loading">
Click <strong>Test Endpoints</strong> to probe SquidWTF mirrors.
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<h2>
API Endpoint Usage
+8
View File
@@ -414,6 +414,14 @@ export async function getSquidWTFBaseUrl() {
);
}
export async function fetchSquidWtfEndpointHealth() {
return requestJson(
"/api/admin/squidwtf/endpoints/test",
{ method: "POST" },
"Failed to test SquidWTF endpoints",
);
}
export async function fetchScrobblingStatus() {
return requestJson(
"/api/admin/scrobbling/status",
+34
View File
@@ -300,6 +300,33 @@ async function clearEndpointUsage() {
}
}
async function fetchSquidWtfEndpointHealth(showFeedback = false) {
const tbody = document.getElementById("squidwtf-endpoints-table-body");
if (tbody) {
tbody.innerHTML =
'<tr><td colspan="3" class="loading"><span class="spinner"></span> Testing SquidWTF endpoints...</td></tr>';
}
try {
const data = await API.fetchSquidWtfEndpointHealth();
UI.updateSquidWtfEndpointHealthUI(data);
if (showFeedback) {
showToast("SquidWTF endpoint test completed", "success");
}
} catch (error) {
console.error("Failed to test SquidWTF endpoints:", error);
if (tbody) {
tbody.innerHTML =
'<tr><td colspan="3" style="text-align:center;color:var(--error);padding:40px;">Failed to test SquidWTF endpoints</td></tr>';
}
if (showFeedback) {
showToast("Failed to test SquidWTF endpoints", "error");
}
}
}
function startPlaylistAutoRefresh() {
if (playlistAutoRefreshInterval) {
clearInterval(playlistAutoRefreshInterval);
@@ -370,6 +397,11 @@ async function loadDashboardData() {
fetchEndpointUsage(),
]);
const endpointsTab = document.getElementById("tab-endpoints");
if (endpointsTab && endpointsTab.classList.contains("active")) {
await fetchSquidWtfEndpointHealth(false);
}
// Ensure user filter defaults are populated before loading Link Playlists rows.
await fetchJellyfinUsers();
await fetchJellyfinPlaylists();
@@ -529,6 +561,7 @@ export function initDashboardData(options) {
window.fetchJellyfinUsers = fetchJellyfinUsers;
window.fetchEndpointUsage = fetchEndpointUsage;
window.clearEndpointUsage = clearEndpointUsage;
window.fetchSquidWtfEndpointHealth = fetchSquidWtfEndpointHealth;
return {
stopDashboardRefresh,
@@ -540,5 +573,6 @@ export function initDashboardData(options) {
fetchJellyfinPlaylists,
fetchConfig,
fetchStatus,
fetchSquidWtfEndpointHealth,
};
}
+10
View File
@@ -179,6 +179,16 @@ document.addEventListener("DOMContentLoaded", () => {
});
}
const endpointsTab = document.querySelector('.tab[data-tab="endpoints"]');
if (endpointsTab) {
endpointsTab.addEventListener("click", () => {
if (authSession.isAuthenticated() && authSession.isAdminSession()) {
window.fetchEndpointUsage?.();
window.fetchSquidWtfEndpointHealth?.(false);
}
});
}
authSession.bootstrapAuth();
});
+104
View File
@@ -783,6 +783,110 @@ export function updateEndpointUsageUI(data) {
.join("");
}
export function updateSquidWtfEndpointHealthUI(data) {
const tbody = document.getElementById("squidwtf-endpoints-table-body");
const testedAt = document.getElementById("squidwtf-endpoints-tested-at");
const endpoints = data.Endpoints || data.endpoints || [];
const testedAtValue = data.TestedAtUtc || data.testedAtUtc;
if (testedAt) {
testedAt.textContent = testedAtValue
? `Last tested ${new Date(testedAtValue).toLocaleString()}`
: "Not tested yet";
}
if (!tbody) {
return;
}
if (endpoints.length === 0) {
tbody.innerHTML =
'<tr><td colspan="3" style="text-align:center;color:var(--text-secondary);padding:40px;">No SquidWTF endpoints configured.</td></tr>';
return;
}
tbody.innerHTML = endpoints
.map((row) => {
const apiResult = normalizeProbeResult(row.Api || row.api);
const streamingResult = normalizeProbeResult(row.Streaming || row.streaming);
const host = row.Host || row.host || "-";
return `
<tr>
<td>
<strong>${escapeHtml(host)}</strong>
</td>
<td style="text-align:center;">
${renderProbeDot(apiResult, "api")}
</td>
<td style="text-align:center;">
${renderProbeDot(streamingResult, "streaming")}
</td>
</tr>
`;
})
.join("");
}
function normalizeProbeResult(result) {
if (!result) {
return {
configured: false,
isUp: false,
state: "unknown",
statusCode: null,
latencyMs: null,
requestUrl: null,
error: null,
};
}
return {
configured: result.Configured ?? result.configured ?? false,
isUp: result.IsUp ?? result.isUp ?? false,
state: result.State ?? result.state ?? "unknown",
statusCode: result.StatusCode ?? result.statusCode ?? null,
latencyMs: result.LatencyMs ?? result.latencyMs ?? null,
requestUrl: result.RequestUrl ?? result.requestUrl ?? null,
error: result.Error ?? result.error ?? null,
};
}
function renderProbeDot(result, type) {
const state = result.state || "unknown";
const isUp = result.isUp === true;
const variant = isUp
? type
: state === "missing"
? "unknown"
: "down";
const titleParts = [];
if (type === "api") {
titleParts.push(isUp ? "API up" : "API down");
} else {
titleParts.push(isUp ? "Streaming up" : "Streaming down");
}
if (result.statusCode != null) {
titleParts.push(`HTTP ${result.statusCode}`);
}
if (result.latencyMs != null) {
titleParts.push(`${result.latencyMs}ms`);
}
if (result.error) {
titleParts.push(result.error);
}
if (result.requestUrl) {
titleParts.push(result.requestUrl);
}
return `<span class="endpoint-health-dot ${variant}" title="${escapeHtml(titleParts.join(" • "))}"></span>`;
}
export function showErrorState(message) {
const statusBadge = document.getElementById("spotify-status");
if (statusBadge) {
+78
View File
@@ -97,6 +97,84 @@ body {
text-decoration: underline;
}
.endpoint-health-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.endpoint-health-legend {
display: flex;
gap: 14px;
flex-wrap: wrap;
color: var(--text-secondary);
font-size: 0.85rem;
}
.endpoint-health-legend span {
display: inline-flex;
align-items: center;
gap: 8px;
}
.endpoint-health-last-tested {
color: var(--text-secondary);
font-size: 0.85rem;
}
.endpoint-health-dot {
width: 12px;
height: 12px;
display: inline-block;
border-radius: 999px;
background: var(--text-secondary);
box-shadow: inset 0 0 0 1px rgba(13, 17, 23, 0.25);
}
.endpoint-health-dot.api,
.endpoint-health-dot.up.api {
background: var(--success);
}
.endpoint-health-dot.streaming,
.endpoint-health-dot.up.streaming {
background: var(--accent);
}
.endpoint-health-dot.down {
background: var(--error);
}
.endpoint-health-dot.unknown {
background: var(--text-secondary);
}
.endpoint-health-table td,
.endpoint-health-table th {
vertical-align: middle;
}
.endpoint-link-cell {
max-width: 260px;
}
.endpoint-url {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: monospace;
font-size: 0.82rem;
}
.endpoint-url.muted {
color: var(--text-secondary);
}
.container {
max-width: 1200px;
margin: 0 auto;