Compare commits

..

32 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
joshpatra 8239316019 chore: version bump 2026-04-06 03:02:50 -04:00
joshpatra e8e7f69e13 fix(search): add jellyfin-compatible external item fields
CI / build-and-test (push) Has been cancelled
2026-04-05 17:41:24 -04:00
joshpatra 815a75fd56 feat(search): implement fifo queue merge scoring 2026-04-05 17:39:46 -04:00
joshpatra 9d58cdd1bd tune(search): restore jellyfin lead boost 2026-04-05 17:16:20 -04:00
joshpatra 806511d727 fix(search): preserve native source ordering 2026-04-05 17:14:49 -04:00
joshpatra 02967c8c67 chore: version bump
CI / build-and-test (push) Has been cancelled
2026-04-04 17:34:38 -04:00
joshpatra bf6fa4e647 Add support footer and login badge to admin UI 2026-04-04 16:19:30 -04:00
joshpatra 04e0c357aa fix(search: true interleaving 2026-04-04 16:18:03 -04:00
joshpatra ee98464475 fix(jellyfin): return cached search responses as raw json
CI / build-and-test (push) Has been cancelled
2026-04-03 15:17:29 -04:00
joshpatra 66f64d6de7 fix: preserve Jellyfin remote control sessions
Forward session control requests transparently and avoid synthetic websocket or capability state overriding proxied client sockets.
2026-04-03 14:02:54 -04:00
joshpatra 8d3fde8fb9 fix: stale playlist artwork
CI / build-and-test (push) Has been cancelled
2026-03-30 02:40:29 -04:00
joshpatra 51d3d784b5 fix: performance improvements 2
CI / build-and-test (push) Has been cancelled
2026-03-30 02:12:22 -04:00
joshpatra dbc7bd6ea1 fix: performance improvements 2026-03-30 02:01:58 -04:00
joshpatra b54d41f560 feat: performance improvement for uninjected playlists 2026-03-30 01:56:26 -04:00
joshpatra 877d2ffddf v1.4.4: re-releasing tag
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-03-25 16:30:51 -04:00
62 changed files with 3743 additions and 1734 deletions
+35
View File
@@ -100,4 +100,39 @@ public class AuthHeaderHelperTests
Assert.Contains("Version=\"1.0\"", header); Assert.Contains("Version=\"1.0\"", header);
Assert.Contains("Token=\"abc\"", 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, Enabled = false,
ConnectionString = "localhost:6379" 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 spotifyCookieLogger = new Mock<ILogger<SpotifySessionCookieService>>();
var spotifySessionCookieService = new SpotifySessionCookieService( var spotifySessionCookieService = new SpotifySessionCookieService(
Options.Create(new SpotifyApiSettings()), 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);
}
}
+74 -5
View File
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -20,6 +21,7 @@ public class JellyfinProxyServiceTests
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory; private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
private readonly RedisCacheService _cache; private readonly RedisCacheService _cache;
private readonly JellyfinSettings _settings; private readonly JellyfinSettings _settings;
private readonly IHttpContextAccessor _httpContextAccessor;
public JellyfinProxyServiceTests() public JellyfinProxyServiceTests()
{ {
@@ -31,7 +33,7 @@ public class JellyfinProxyServiceTests
var redisSettings = new RedisSettings { Enabled = false }; var redisSettings = new RedisSettings { Enabled = false };
var mockCacheLogger = new Mock<ILogger<RedisCacheService>>(); 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 _settings = new JellyfinSettings
{ {
@@ -45,19 +47,21 @@ public class JellyfinProxyServiceTests
}; };
var httpContext = new DefaultHttpContext(); var httpContext = new DefaultHttpContext();
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; _httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
var mockLogger = new Mock<ILogger<JellyfinProxyService>>(); var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
var userResolver = CreateUserContextResolver(_httpContextAccessor);
// Initialize cache settings for tests // Initialize cache settings for tests
var serviceCollection = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); var serviceCollection = new Microsoft.Extensions.DependencyInjection.ServiceCollection();
serviceCollection.Configure<CacheSettings>(options => { }); // Use defaults serviceCollection.Configure<CacheSettings>(options => { }); // Use defaults
var serviceProvider = serviceCollection.BuildServiceProvider(); var serviceProvider = serviceCollection.BuildServiceProvider();
CacheExtensions.InitializeCacheSettings(serviceProvider); allstarr.Services.Common.CacheExtensions.InitializeCacheSettings(serviceProvider);
_service = new JellyfinProxyService( _service = new JellyfinProxyService(
_mockHttpClientFactory.Object, _mockHttpClientFactory.Object,
Options.Create(_settings), Options.Create(_settings),
httpContextAccessor, _httpContextAccessor,
userResolver,
mockLogger.Object, mockLogger.Object,
_cache); _cache);
} }
@@ -93,6 +97,21 @@ public class JellyfinProxyServiceTests
Assert.Equal(500, statusCode); 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] [Fact]
public async Task GetJsonAsync_WithoutClientHeaders_SendsNoAuth() public async Task GetJsonAsync_WithoutClientHeaders_SendsNoAuth()
{ {
@@ -228,6 +247,44 @@ public class JellyfinProxyServiceTests
Assert.Equal("test query", searchTermValue); 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] [Fact]
public async Task GetItemAsync_RequestsCorrectEndpoint() public async Task GetItemAsync_RequestsCorrectEndpoint()
{ {
@@ -629,12 +686,14 @@ public class JellyfinProxyServiceTests
var mockLogger = new Mock<ILogger<JellyfinProxyService>>(); var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
var redisSettings = new RedisSettings { Enabled = false }; var redisSettings = new RedisSettings { Enabled = false };
var mockCacheLogger = new Mock<ILogger<RedisCacheService>>(); 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( var service = new JellyfinProxyService(
_mockHttpClientFactory.Object, _mockHttpClientFactory.Object,
Options.Create(_settings), Options.Create(_settings),
httpContextAccessor, httpContextAccessor,
userResolver,
mockLogger.Object, mockLogger.Object,
cache); cache);
@@ -660,4 +719,14 @@ public class JellyfinProxyServiceTests
ItExpr.IsAny<CancellationToken>()) ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response); .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.Reflection;
using System.Text.Json;
using allstarr.Controllers; using allstarr.Controllers;
using allstarr.Models.Jellyfin;
namespace allstarr.Tests; namespace allstarr.Tests;
@@ -8,16 +10,16 @@ public class JellyfinSearchResponseSerializationTests
[Fact] [Fact]
public void SerializeSearchResponseJson_PreservesPascalCaseShape() public void SerializeSearchResponseJson_PreservesPascalCaseShape()
{ {
var payload = new var payload = new JellyfinItemsResponse
{ {
Items = new[] Items =
{ [
new Dictionary<string, object?> new Dictionary<string, object?>
{ {
["Name"] = "BTS", ["Name"] = "BTS",
["Type"] = "MusicAlbum" ["Type"] = "MusicAlbum"
} }
}, ],
TotalRecordCount = 1, TotalRecordCount = 1,
StartIndex = 0 StartIndex = 0
}; };
@@ -28,11 +30,64 @@ public class JellyfinSearchResponseSerializationTests
Assert.NotNull(method); Assert.NotNull(method);
var closedMethod = method!.MakeGenericMethod(payload.GetType()); var json = (string)method!.Invoke(null, new object?[] { payload })!;
var json = (string)closedMethod.Invoke(null, new object?[] { payload })!;
Assert.Equal( Assert.Equal(
"{\"Items\":[{\"Name\":\"BTS\",\"Type\":\"MusicAlbum\"}],\"TotalRecordCount\":1,\"StartIndex\":0}", "{\"Items\":[{\"Name\":\"BTS\",\"Type\":\"MusicAlbum\"}],\"TotalRecordCount\":1,\"StartIndex\":0}",
json); 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() public async Task RemoveSessionAsync_ReportsPlaybackStopButDoesNotLogoutUserSession()
{ {
var requestedPaths = new ConcurrentBag<string>(); var requestedPaths = new ConcurrentBag<string>();
var requestBodies = new ConcurrentDictionary<string, string>();
var handler = new DelegateHttpMessageHandler((request, _) => 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)); return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent));
}); });
@@ -89,6 +97,12 @@ public class JellyfinSessionManagerTests
Assert.Contains("/Sessions/Capabilities/Full", requestedPaths); Assert.Contains("/Sessions/Capabilities/Full", requestedPaths);
Assert.Contains("/Sessions/Playing/Stopped", requestedPaths); Assert.Contains("/Sessions/Playing/Stopped", requestedPaths);
Assert.DoesNotContain("/Sessions/Logout", 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] [Fact]
@@ -182,12 +196,19 @@ public class JellyfinSessionManagerTests
var cache = new RedisCacheService( var cache = new RedisCacheService(
Options.Create(new RedisSettings { Enabled = false }), 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( return new JellyfinProxyService(
httpClientFactory, httpClientFactory,
Options.Create(settings), Options.Create(settings),
httpContextAccessor, 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, NullLogger<JellyfinProxyService>.Instance,
cache); cache);
} }
+2 -1
View File
@@ -1,5 +1,6 @@
using Xunit; using Xunit;
using Moq; using Moq;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using allstarr.Services.Lyrics; using allstarr.Services.Lyrics;
using allstarr.Services.Common; using allstarr.Services.Common;
@@ -23,7 +24,7 @@ public class LrclibServiceTests
// Create mock Redis cache // Create mock Redis cache
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>(); var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false }); 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 _httpClient = new HttpClient
{ {
+200 -16
View File
@@ -1,8 +1,12 @@
using Xunit; using Xunit;
using Moq; using Moq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using System.Text.Json;
using allstarr.Models.Domain;
using allstarr.Models.Spotify;
using allstarr.Services.Common; using allstarr.Services.Common;
using allstarr.Models.Settings; 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] [Fact]
public void Constructor_InitializesWithSettings() public void Constructor_InitializesWithSettings()
{ {
// Act // Act
var service = new RedisCacheService(_settings, _mockLogger.Object); var service = CreateService();
// Assert // Assert
Assert.NotNull(service); Assert.NotNull(service);
@@ -45,7 +57,7 @@ public class RedisCacheServiceTests
}); });
// Act - Constructor will try to connect but should handle failure gracefully // 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 - Service should be created even if connection fails
Assert.NotNull(service); Assert.NotNull(service);
@@ -55,7 +67,7 @@ public class RedisCacheServiceTests
public async Task GetStringAsync_WhenDisabled_ReturnsNull() public async Task GetStringAsync_WhenDisabled_ReturnsNull()
{ {
// Arrange // Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object); var service = CreateService();
// Act // Act
var result = await service.GetStringAsync("test:key"); var result = await service.GetStringAsync("test:key");
@@ -68,7 +80,7 @@ public class RedisCacheServiceTests
public async Task GetAsync_WhenDisabled_ReturnsNull() public async Task GetAsync_WhenDisabled_ReturnsNull()
{ {
// Arrange // Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object); var service = CreateService();
// Act // Act
var result = await service.GetAsync<TestObject>("test:key"); var result = await service.GetAsync<TestObject>("test:key");
@@ -81,7 +93,7 @@ public class RedisCacheServiceTests
public async Task SetStringAsync_WhenDisabled_ReturnsFalse() public async Task SetStringAsync_WhenDisabled_ReturnsFalse()
{ {
// Arrange // Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object); var service = CreateService();
// Act // Act
var result = await service.SetStringAsync("test:key", "test value"); var result = await service.SetStringAsync("test:key", "test value");
@@ -94,7 +106,7 @@ public class RedisCacheServiceTests
public async Task SetAsync_WhenDisabled_ReturnsFalse() public async Task SetAsync_WhenDisabled_ReturnsFalse()
{ {
// Arrange // Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object); var service = CreateService();
var testObj = new TestObject { Id = 1, Name = "Test" }; var testObj = new TestObject { Id = 1, Name = "Test" };
// Act // Act
@@ -108,7 +120,7 @@ public class RedisCacheServiceTests
public async Task DeleteAsync_WhenDisabled_ReturnsFalse() public async Task DeleteAsync_WhenDisabled_ReturnsFalse()
{ {
// Arrange // Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object); var service = CreateService();
// Act // Act
var result = await service.DeleteAsync("test:key"); var result = await service.DeleteAsync("test:key");
@@ -121,7 +133,7 @@ public class RedisCacheServiceTests
public async Task ExistsAsync_WhenDisabled_ReturnsFalse() public async Task ExistsAsync_WhenDisabled_ReturnsFalse()
{ {
// Arrange // Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object); var service = CreateService();
// Act // Act
var result = await service.ExistsAsync("test:key"); var result = await service.ExistsAsync("test:key");
@@ -134,7 +146,7 @@ public class RedisCacheServiceTests
public async Task DeleteByPatternAsync_WhenDisabled_ReturnsZero() public async Task DeleteByPatternAsync_WhenDisabled_ReturnsZero()
{ {
// Arrange // Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object); var service = CreateService();
// Act // Act
var result = await service.DeleteByPatternAsync("test:*"); var result = await service.DeleteByPatternAsync("test:*");
@@ -147,7 +159,7 @@ public class RedisCacheServiceTests
public async Task SetStringAsync_WithExpiry_AcceptsTimeSpan() public async Task SetStringAsync_WithExpiry_AcceptsTimeSpan()
{ {
// Arrange // Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object); var service = CreateService();
var expiry = TimeSpan.FromHours(1); var expiry = TimeSpan.FromHours(1);
// Act // Act
@@ -161,7 +173,7 @@ public class RedisCacheServiceTests
public async Task SetAsync_WithExpiry_AcceptsTimeSpan() public async Task SetAsync_WithExpiry_AcceptsTimeSpan()
{ {
// Arrange // Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object); var service = CreateService();
var testObj = new TestObject { Id = 1, Name = "Test" }; var testObj = new TestObject { Id = 1, Name = "Test" };
var expiry = TimeSpan.FromDays(30); var expiry = TimeSpan.FromDays(30);
@@ -176,14 +188,14 @@ public class RedisCacheServiceTests
public void IsEnabled_ReflectsSettings() public void IsEnabled_ReflectsSettings()
{ {
// Arrange // Arrange
var disabledService = new RedisCacheService(_settings, _mockLogger.Object); var disabledService = CreateService();
var enabledSettings = Options.Create(new RedisSettings var enabledSettings = Options.Create(new RedisSettings
{ {
Enabled = true, Enabled = true,
ConnectionString = "localhost:6379" ConnectionString = "localhost:6379"
}); });
var enabledService = new RedisCacheService(enabledSettings, _mockLogger.Object); var enabledService = CreateService(settings: enabledSettings);
// Assert // Assert
Assert.False(disabledService.IsEnabled); Assert.False(disabledService.IsEnabled);
@@ -194,7 +206,7 @@ public class RedisCacheServiceTests
public async Task GetAsync_DeserializesComplexObjects() public async Task GetAsync_DeserializesComplexObjects()
{ {
// Arrange // Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object); var service = CreateService();
// Act // Act
var result = await service.GetAsync<ComplexTestObject>("test:complex"); var result = await service.GetAsync<ComplexTestObject>("test:complex");
@@ -207,7 +219,7 @@ public class RedisCacheServiceTests
public async Task SetAsync_SerializesComplexObjects() public async Task SetAsync_SerializesComplexObjects()
{ {
// Arrange // Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object); var service = CreateService();
var complexObj = new ComplexTestObject var complexObj = new ComplexTestObject
{ {
Id = 1, Id = 1,
@@ -238,12 +250,184 @@ public class RedisCacheServiceTests
}); });
// Act // Act
var service = new RedisCacheService(customSettings, _mockLogger.Object); var service = CreateService(settings: customSettings);
// Assert // Assert
Assert.NotNull(service); 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 private class TestObject
{ {
public int Id { get; set; } public int Id { get; set; }
-25
View File
@@ -157,31 +157,6 @@ public class SpotifyApiClientTests
Assert.Equal(new DateTime(2026, 2, 16, 5, 0, 0, DateTimeKind.Utc), track.AddedAt); Assert.Equal(new DateTime(2026, 2, 16, 5, 0, 0, DateTimeKind.Utc), track.AddedAt);
} }
[Fact]
public void TryGetSpotifyPlaylistItemCount_ParsesAttributesArrayEntries()
{
// Arrange
using var doc = JsonDocument.Parse("""
{
"attributes": [
{ "key": "core:item_count", "value": "42" }
]
}
""");
var method = typeof(SpotifyApiClient).GetMethod(
"TryGetSpotifyPlaylistItemCount",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.NotNull(method);
// Act
var result = (int)method!.Invoke(null, new object?[] { doc.RootElement })!;
// Assert
Assert.Equal(42, result);
}
private static T InvokePrivateMethod<T>(object instance, string methodName, params object?[] args) private static T InvokePrivateMethod<T>(object instance, string methodName, params object?[] args)
{ {
var method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic); var method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic);
+2 -1
View File
@@ -2,6 +2,7 @@ using Xunit;
using Moq; using Moq;
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using allstarr.Services.Spotify; using allstarr.Services.Spotify;
@@ -30,7 +31,7 @@ public class SpotifyMappingServiceTests
ConnectionString = "localhost:6379" 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); _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( var cache = new RedisCacheService(
Options.Create(new RedisSettings { Enabled = false }), 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); var odesliService = new OdesliService(_httpClientFactoryMock.Object, _odesliLoggerMock.Object, cache);
@@ -1,5 +1,6 @@
using Xunit; using Xunit;
using Moq; using Moq;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using allstarr.Services.SquidWTF; using allstarr.Services.SquidWTF;
@@ -42,7 +43,10 @@ public class SquidWTFMetadataServiceTests
// Create mock Redis cache // Create mock Redis cache
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>(); var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false }); 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> _apiUrls = new List<string>
{ {
+1 -1
View File
@@ -9,5 +9,5 @@ public static class AppVersion
/// <summary> /// <summary>
/// Current application version. /// Current application version.
/// </summary> /// </summary>
public const string Version = "1.5.2"; public const string Version = "1.4.7";
} }
+4 -4
View File
@@ -637,11 +637,11 @@ public class ConfigController : ControllerBase
{ {
var keysToDelete = new[] var keysToDelete = new[]
{ {
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name), CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name), CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
$"spotify:matched:{playlist.Name}", // Legacy key $"spotify:matched:{playlist.Name}", // Legacy key
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name), CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name) CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId)
}; };
foreach (var key in keysToDelete) foreach (var key in keysToDelete)
+293 -1
View File
@@ -9,7 +9,9 @@ using allstarr.Services.Admin;
using allstarr.Services.Spotify; using allstarr.Services.Spotify;
using allstarr.Services.Scrobbling; using allstarr.Services.Scrobbling;
using allstarr.Services.SquidWTF; using allstarr.Services.SquidWTF;
using System.Diagnostics;
using System.Runtime; using System.Runtime;
using System.Text.Json;
namespace allstarr.Controllers; namespace allstarr.Controllers;
@@ -18,6 +20,9 @@ namespace allstarr.Controllers;
[ServiceFilter(typeof(AdminPortFilter))] [ServiceFilter(typeof(AdminPortFilter))]
public class DiagnosticsController : ControllerBase 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 ILogger<DiagnosticsController> _logger;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly SpotifyApiSettings _spotifyApiSettings; private readonly SpotifyApiSettings _spotifyApiSettings;
@@ -29,6 +34,8 @@ public class DiagnosticsController : ControllerBase
private readonly RedisCacheService _cache; private readonly RedisCacheService _cache;
private readonly SpotifySessionCookieService _spotifySessionCookieService; private readonly SpotifySessionCookieService _spotifySessionCookieService;
private readonly List<string> _squidWtfApiUrls; private readonly List<string> _squidWtfApiUrls;
private readonly List<string> _squidWtfStreamingUrls;
private readonly IHttpClientFactory _httpClientFactory;
private static int _urlIndex = 0; private static int _urlIndex = 0;
private static readonly object _urlIndexLock = new(); private static readonly object _urlIndexLock = new();
@@ -43,7 +50,8 @@ public class DiagnosticsController : ControllerBase
IOptions<SquidWTFSettings> squidWtfSettings, IOptions<SquidWTFSettings> squidWtfSettings,
SpotifySessionCookieService spotifySessionCookieService, SpotifySessionCookieService spotifySessionCookieService,
SquidWtfEndpointCatalog squidWtfEndpointCatalog, SquidWtfEndpointCatalog squidWtfEndpointCatalog,
RedisCacheService cache) RedisCacheService cache,
IHttpClientFactory httpClientFactory)
{ {
_logger = logger; _logger = logger;
_configuration = configuration; _configuration = configuration;
@@ -56,6 +64,8 @@ public class DiagnosticsController : ControllerBase
_spotifySessionCookieService = spotifySessionCookieService; _spotifySessionCookieService = spotifySessionCookieService;
_cache = cache; _cache = cache;
_squidWtfApiUrls = squidWtfEndpointCatalog.ApiUrls; _squidWtfApiUrls = squidWtfEndpointCatalog.ApiUrls;
_squidWtfStreamingUrls = squidWtfEndpointCatalog.StreamingUrls;
_httpClientFactory = httpClientFactory;
} }
[HttpGet("status")] [HttpGet("status")]
@@ -161,6 +171,61 @@ public class DiagnosticsController : ControllerBase
return Ok(new { baseUrl }); 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> /// <summary>
/// Get current configuration including cache settings /// Get current configuration including cache settings
/// </summary> /// </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> /// <summary>
@@ -139,56 +139,6 @@ public class DownloadsController : ControllerBase
} }
} }
/// <summary>
/// DELETE /api/admin/downloads/all
/// Deletes all kept audio files and removes empty folders
/// </summary>
[HttpDelete("downloads/all")]
public IActionResult DeleteAllDownloads()
{
try
{
var keptPath = Path.GetFullPath(Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"));
if (!Directory.Exists(keptPath))
{
return Ok(new { success = true, deletedCount = 0, message = "No kept downloads found" });
}
var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" };
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
.ToList();
foreach (var filePath in allFiles)
{
System.IO.File.Delete(filePath);
}
// Clean up empty directories under kept root (deepest first)
var allDirectories = Directory.GetDirectories(keptPath, "*", SearchOption.AllDirectories)
.OrderByDescending(d => d.Length);
foreach (var directory in allDirectories)
{
if (!Directory.EnumerateFileSystemEntries(directory).Any())
{
Directory.Delete(directory);
}
}
return Ok(new
{
success = true,
deletedCount = allFiles.Count,
message = $"Deleted {allFiles.Count} kept download(s)"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete all kept downloads");
return StatusCode(500, new { error = "Failed to delete all kept downloads" });
}
}
/// <summary> /// <summary>
/// GET /api/admin/downloads/file /// GET /api/admin/downloads/file
/// Downloads a specific file from the kept folder /// Downloads a specific file from the kept folder
+38 -8
View File
@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text; using System.Text;
using System.Net.Http; using System.Net.Http;
using allstarr.Models.Domain; using allstarr.Models.Domain;
using allstarr.Models.Settings;
using allstarr.Models.Spotify; using allstarr.Models.Spotify;
using allstarr.Services.Common; using allstarr.Services.Common;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -167,7 +168,7 @@ public partial class JellyfinController
if (!spotifyPlaylistCreatedDates.TryGetValue(playlistName, out var playlistCreatedDate)) if (!spotifyPlaylistCreatedDates.TryGetValue(playlistName, out var playlistCreatedDate))
{ {
playlistCreatedDate = await ResolveSpotifyPlaylistCreatedDateAsync(playlistName); playlistCreatedDate = await ResolveSpotifyPlaylistCreatedDateAsync(playlistConfig);
spotifyPlaylistCreatedDates[playlistName] = playlistCreatedDate; spotifyPlaylistCreatedDates[playlistName] = playlistCreatedDate;
} }
@@ -177,7 +178,16 @@ public partial class JellyfinController
} }
// Get matched external tracks (tracks that were successfully downloaded/matched) // 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); var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
_logger.LogDebug("Cache lookup for {Key}: {Count} matched tracks", _logger.LogDebug("Cache lookup for {Key}: {Count} matched tracks",
@@ -186,7 +196,10 @@ public partial class JellyfinController
// Fallback to legacy cache format // Fallback to legacy cache format
if (matchedTracks == null || matchedTracks.Count == 0) 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); var legacySongs = await _cache.GetAsync<List<Song>>(legacyKey);
if (legacySongs != null && legacySongs.Count > 0) if (legacySongs != null && legacySongs.Count > 0)
{ {
@@ -202,7 +215,10 @@ public partial class JellyfinController
// Prefer the currently served playlist items cache when available. // Prefer the currently served playlist items cache when available.
// This most closely matches what the injected playlist endpoint will return. // This most closely matches what the injected playlist endpoint will return.
var exactServedCount = 0; 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 cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsKey);
var exactServedRunTimeTicks = 0L; var exactServedRunTimeTicks = 0L;
if (cachedPlaylistItems != null && if (cachedPlaylistItems != null &&
@@ -231,7 +247,7 @@ public partial class JellyfinController
var localRunTimeTicks = 0L; var localRunTimeTicks = 0L;
try try
{ {
var userId = _settings.UserId; var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
var playlistItemsUrl = $"Playlists/{playlistId}/Items"; var playlistItemsUrl = $"Playlists/{playlistId}/Items";
var queryParams = new Dictionary<string, string> 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 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 cachedPlaylist = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
var createdAt = GetCreatedDateFromSpotifyPlaylist(cachedPlaylist); var createdAt = GetCreatedDateFromSpotifyPlaylist(cachedPlaylist);
if (createdAt.HasValue) if (createdAt.HasValue)
@@ -351,7 +378,10 @@ public partial class JellyfinController
return null; return null;
} }
var tracks = await _spotifyPlaylistFetcher.GetPlaylistTracksAsync(playlistName); var tracks = await _spotifyPlaylistFetcher.GetPlaylistTracksAsync(
playlistName,
playlistScopeUserId,
playlistConfig.JellyfinId);
var earliestTrackAddedAt = tracks var earliestTrackAddedAt = tracks
.Where(t => t.AddedAt.HasValue) .Where(t => t.AddedAt.HasValue)
.Select(t => t.AddedAt!.Value.ToUniversalTime()) .Select(t => t.AddedAt!.Value.ToUniversalTime())
@@ -245,9 +245,7 @@ public class JellyfinAdminController : ControllerBase
/// Get all playlists from the user's Spotify account /// Get all playlists from the user's Spotify account
/// </summary> /// </summary>
[HttpGet("jellyfin/playlists")] [HttpGet("jellyfin/playlists")]
public async Task<IActionResult> GetJellyfinPlaylists( public async Task<IActionResult> GetJellyfinPlaylists([FromQuery] string? userId = null)
[FromQuery] string? userId = null,
[FromQuery] bool includeStats = true)
{ {
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey)) if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
{ {
@@ -332,13 +330,13 @@ public class JellyfinAdminController : ControllerBase
var statsUserId = requestedUserId; var statsUserId = requestedUserId;
var trackStats = (LocalTracks: 0, ExternalTracks: 0, ExternalAvailable: 0); var trackStats = (LocalTracks: 0, ExternalTracks: 0, ExternalAvailable: 0);
if (isConfigured && includeStats) if (isConfigured)
{ {
trackStats = await GetPlaylistTrackStats(id!, session, statsUserId); trackStats = await GetPlaylistTrackStats(id!, session, statsUserId);
} }
var actualTrackCount = isConfigured var actualTrackCount = isConfigured
? (includeStats ? trackStats.LocalTracks + trackStats.ExternalTracks : childCount) ? trackStats.LocalTracks + trackStats.ExternalTracks
: childCount; : childCount;
playlists.Add(new playlists.Add(new
@@ -351,7 +349,6 @@ public class JellyfinAdminController : ControllerBase
isLinkedByAnotherUser, isLinkedByAnotherUser,
linkedOwnerUserId = scopedLinkedPlaylist?.UserId ?? linkedOwnerUserId = scopedLinkedPlaylist?.UserId ??
allLinkedForPlaylist.FirstOrDefault()?.UserId, allLinkedForPlaylist.FirstOrDefault()?.UserId,
statsPending = isConfigured && !includeStats,
localTracks = trackStats.LocalTracks, localTracks = trackStats.LocalTracks,
externalTracks = trackStats.ExternalTracks, externalTracks = trackStats.ExternalTracks,
externalAvailable = trackStats.ExternalAvailable externalAvailable = trackStats.ExternalAvailable
@@ -1,7 +1,9 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Text.Json; using System.Text.Json;
using System.Globalization; using System.Globalization;
using allstarr.Models.Jellyfin;
using allstarr.Models.Scrobbling; using allstarr.Models.Scrobbling;
using allstarr.Serialization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace allstarr.Controllers; namespace allstarr.Controllers;
@@ -226,7 +228,7 @@ public partial class JellyfinController
// Build minimal playback start with just the ghost UUID // Build minimal playback start with just the ghost UUID
// Don't include the Item object - Jellyfin will just track the session without item details // Don't include the Item object - Jellyfin will just track the session without item details
var playbackStart = new var playbackStart = new JellyfinPlaybackStatePayload
{ {
ItemId = ghostUuid, ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0, PositionTicks = positionTicks ?? 0,
@@ -236,7 +238,7 @@ public partial class JellyfinController
PlayMethod = "DirectPlay" PlayMethod = "DirectPlay"
}; };
var playbackJson = JsonSerializer.Serialize(playbackStart); var playbackJson = AllstarrJsonSerializer.Serialize(playbackStart);
_logger.LogDebug("📤 Sending ghost playback start for external track: {Json}", playbackJson); _logger.LogDebug("📤 Sending ghost playback start for external track: {Json}", playbackJson);
// Forward to Jellyfin with ghost UUID // Forward to Jellyfin with ghost UUID
@@ -357,14 +359,13 @@ public partial class JellyfinController
trackName ?? "Unknown", itemId); trackName ?? "Unknown", itemId);
// Build playback start info - Jellyfin will fetch item details itself // Build playback start info - Jellyfin will fetch item details itself
var playbackStart = new var playbackStart = new JellyfinPlaybackStatePayload
{ {
ItemId = itemId, ItemId = itemId ?? string.Empty,
PositionTicks = positionTicks ?? 0, PositionTicks = positionTicks ?? 0
// Let Jellyfin fetch the item details - don't include NowPlayingItem
}; };
var playbackJson = JsonSerializer.Serialize(playbackStart); var playbackJson = AllstarrJsonSerializer.Serialize(playbackStart);
_logger.LogDebug("📤 Sending playback start: {Json}", playbackJson); _logger.LogDebug("📤 Sending playback start: {Json}", playbackJson);
var (result, statusCode) = var (result, statusCode) =
@@ -624,7 +625,7 @@ public partial class JellyfinController
externalId); externalId);
var inferredStartGhostUuid = GenerateUuidFromString(itemId); var inferredStartGhostUuid = GenerateUuidFromString(itemId);
var inferredExternalStartPayload = JsonSerializer.Serialize(new var inferredExternalStartPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
{ {
ItemId = inferredStartGhostUuid, ItemId = inferredStartGhostUuid,
PositionTicks = positionTicks ?? 0, PositionTicks = positionTicks ?? 0,
@@ -692,7 +693,7 @@ public partial class JellyfinController
var ghostUuid = GenerateUuidFromString(itemId); var ghostUuid = GenerateUuidFromString(itemId);
// Build progress report with ghost UUID // Build progress report with ghost UUID
var progressReport = new var progressReport = new JellyfinPlaybackStatePayload
{ {
ItemId = ghostUuid, ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0, PositionTicks = positionTicks ?? 0,
@@ -702,7 +703,7 @@ public partial class JellyfinController
PlayMethod = "DirectPlay" PlayMethod = "DirectPlay"
}; };
var progressJson = JsonSerializer.Serialize(progressReport); var progressJson = AllstarrJsonSerializer.Serialize(progressReport);
// Forward to Jellyfin with ghost UUID // Forward to Jellyfin with ghost UUID
var (progressResult, progressStatusCode) = var (progressResult, progressStatusCode) =
@@ -773,7 +774,7 @@ public partial class JellyfinController
_logger.LogInformation("🎵 Local track playback started (inferred from progress): {Name} (ID: {ItemId})", _logger.LogInformation("🎵 Local track playback started (inferred from progress): {Name} (ID: {ItemId})",
trackName ?? "Unknown", itemId); trackName ?? "Unknown", itemId);
var inferredStartPayload = JsonSerializer.Serialize(new var inferredStartPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
{ {
ItemId = itemId, ItemId = itemId,
PositionTicks = positionTicks ?? 0 PositionTicks = positionTicks ?? 0
@@ -948,7 +949,7 @@ public partial class JellyfinController
} }
var ghostUuid = GenerateUuidFromString(previousItemId); var ghostUuid = GenerateUuidFromString(previousItemId);
var inferredExternalStopPayload = JsonSerializer.Serialize(new var inferredExternalStopPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
{ {
ItemId = ghostUuid, ItemId = ghostUuid,
PositionTicks = previousPositionTicks ?? 0, PositionTicks = previousPositionTicks ?? 0,
@@ -997,7 +998,7 @@ public partial class JellyfinController
}); });
} }
var inferredStopPayload = JsonSerializer.Serialize(new var inferredStopPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
{ {
ItemId = previousItemId, ItemId = previousItemId,
PositionTicks = previousPositionTicks ?? 0, PositionTicks = previousPositionTicks ?? 0,
@@ -1062,7 +1063,7 @@ public partial class JellyfinController
return; return;
} }
var userId = ResolvePlaybackUserId(progressPayload); var userId = await ResolvePlaybackUserIdAsync(progressPayload);
if (string.IsNullOrWhiteSpace(userId)) if (string.IsNullOrWhiteSpace(userId))
{ {
_logger.LogDebug("Skipping local played signal for {ItemId} - no user id available", itemId); _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) && if (progressPayload.TryGetProperty("UserId", out var userIdElement) &&
userIdElement.ValueKind == JsonValueKind.String) userIdElement.ValueKind == JsonValueKind.String)
@@ -1116,7 +1117,7 @@ public partial class JellyfinController
return queryUserId; return queryUserId;
} }
return _settings.UserId; return await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
} }
private static int? ToPlaybackPositionSeconds(long? positionTicks) private static int? ToPlaybackPositionSeconds(long? positionTicks)
@@ -1294,13 +1295,13 @@ public partial class JellyfinController
// Report stop to Jellyfin with ghost UUID // Report stop to Jellyfin with ghost UUID
var ghostUuid = GenerateUuidFromString(itemId); var ghostUuid = GenerateUuidFromString(itemId);
var externalStopInfo = new var externalStopInfo = new JellyfinPlaybackStatePayload
{ {
ItemId = ghostUuid, ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0 PositionTicks = positionTicks ?? 0
}; };
var stopJson = JsonSerializer.Serialize(externalStopInfo); var stopJson = AllstarrJsonSerializer.Serialize(externalStopInfo);
_logger.LogDebug("📤 Sending ghost playback stop for external track: {Json}", stopJson); _logger.LogDebug("📤 Sending ghost playback stop for external track: {Json}", stopJson);
var (stopResult, stopStatusCode) = var (stopResult, stopStatusCode) =
@@ -1469,7 +1470,7 @@ public partial class JellyfinController
stopInfo["PositionTicks"] = positionTicks.Value; stopInfo["PositionTicks"] = positionTicks.Value;
} }
body = JsonSerializer.Serialize(stopInfo); body = AllstarrJsonSerializer.Serialize(stopInfo);
_logger.LogDebug("📤 Sending playback stop body (IsPaused=false, {BodyLength} bytes)", body.Length); _logger.LogDebug("📤 Sending playback stop body (IsPaused=false, {BodyLength} bytes)", body.Length);
var (result, statusCode) = var (result, statusCode) =
+303 -74
View File
@@ -1,8 +1,11 @@
using System.Buffers;
using System.Text.Json; using System.Text.Json;
using System.Text; using System.Text;
using allstarr.Models.Domain; using allstarr.Models.Domain;
using allstarr.Models.Jellyfin;
using allstarr.Models.Search; using allstarr.Models.Search;
using allstarr.Models.Subsonic; using allstarr.Models.Subsonic;
using allstarr.Serialization;
using allstarr.Services.Common; using allstarr.Services.Common;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -25,6 +28,7 @@ public partial class JellyfinController
[FromQuery] int startIndex = 0, [FromQuery] int startIndex = 0,
[FromQuery] string? parentId = null, [FromQuery] string? parentId = null,
[FromQuery] string? artistIds = null, [FromQuery] string? artistIds = null,
[FromQuery] string? contributingArtistIds = null,
[FromQuery] string? albumArtistIds = null, [FromQuery] string? albumArtistIds = null,
[FromQuery] string? albumIds = null, [FromQuery] string? albumIds = null,
[FromQuery] string? sortBy = null, [FromQuery] string? sortBy = null,
@@ -39,8 +43,8 @@ public partial class JellyfinController
var effectiveArtistIds = albumArtistIds ?? artistIds; var effectiveArtistIds = albumArtistIds ?? artistIds;
var favoritesOnlyRequest = IsFavoritesOnlyRequest(); var favoritesOnlyRequest = IsFavoritesOnlyRequest();
_logger.LogDebug("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, albumArtistIds={AlbumArtistIds}, albumIds={AlbumIds}, userId={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, albumArtistIds, albumIds, userId); searchTerm, includeItemTypes, parentId, artistIds, contributingArtistIds, albumArtistIds, albumIds, userId);
_logger.LogInformation( _logger.LogInformation(
"SEARCH TRACE: rawQuery='{RawQuery}', boundSearchTerm='{BoundSearchTerm}', effectiveSearchTerm='{EffectiveSearchTerm}', includeItemTypes='{IncludeItemTypes}'", "SEARCH TRACE: rawQuery='{RawQuery}', boundSearchTerm='{BoundSearchTerm}', effectiveSearchTerm='{EffectiveSearchTerm}', includeItemTypes='{IncludeItemTypes}'",
Request.QueryString.Value ?? string.Empty, Request.QueryString.Value ?? string.Empty,
@@ -51,15 +55,34 @@ public partial class JellyfinController
// ============================================================================ // ============================================================================
// REQUEST ROUTING LOGIC (Priority Order) // REQUEST ROUTING LOGIC (Priority Order)
// ============================================================================ // ============================================================================
// 1. ArtistIds present (external) → Handle external artists (even with ParentId) // 1. ContributingArtistIds present (external) → Handle external "appears on" albums
// 2. AlbumIds present (external) → Handle external albums (even with ParentId) // 2. ArtistIds present (external) → Handle external artists (even with ParentId)
// 3. ParentId present → GetChildItems (handles external playlists/albums/artists OR proxies library items) // 3. AlbumIds present (external) → Handle external albums (even with ParentId)
// 4. ArtistIds present (library) → Proxy to Jellyfin with artist filter // 4. ParentId present → GetChildItems (handles external playlists/albums/artists OR proxies library items)
// 5. SearchTerm present → Integrated search (Jellyfin + external sources) // 5. ArtistIds / ContributingArtistIds present (library) → Proxy to Jellyfin with full filter
// 6. Otherwise → Proxy browse request transparently to Jellyfin // 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)) if (!string.IsNullOrWhiteSpace(effectiveArtistIds))
{ {
var artistId = effectiveArtistIds.Split(',')[0]; // Take first artist if multiple var artistId = effectiveArtistIds.Split(',')[0]; // Take first artist if multiple
@@ -87,7 +110,7 @@ public partial class JellyfinController
// If library artist, fall through to handle with ParentId or proxy // 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)) if (!string.IsNullOrWhiteSpace(albumIds))
{ {
var albumId = albumIds.Split(',')[0]; // Take first album if multiple var albumId = albumIds.Split(',')[0]; // Take first album if multiple
@@ -107,23 +130,17 @@ public partial class JellyfinController
var album = await _metadataService.GetAlbumAsync(provider!, externalId!, HttpContext.RequestAborted); var album = await _metadataService.GetAlbumAsync(provider!, externalId!, HttpContext.RequestAborted);
if (album == null) if (album == null)
{ {
return new JsonResult(new return CreateItemsResponse([], 0, startIndex);
{ Items = Array.Empty<object>(), TotalRecordCount = 0, StartIndex = startIndex });
} }
var albumItems = album.Songs.Select(song => _responseBuilder.ConvertSongToJellyfinItem(song)).ToList(); var albumItems = album.Songs.Select(song => _responseBuilder.ConvertSongToJellyfinItem(song)).ToList();
return new JsonResult(new return CreateItemsResponse(albumItems, albumItems.Count, startIndex);
{
Items = albumItems,
TotalRecordCount = albumItems.Count,
StartIndex = startIndex
});
} }
// If library album, fall through to handle with ParentId or proxy // 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)) if (!string.IsNullOrWhiteSpace(parentId))
{ {
// Check if this is an external playlist // Check if this is an external playlist
@@ -165,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)) if (!string.IsNullOrWhiteSpace(effectiveArtistIds))
{ {
// Library artist - proxy transparently with full query string // Library artist - proxy transparently with full query string
@@ -177,7 +204,7 @@ public partial class JellyfinController
return HandleProxyResponse(result, statusCode); 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)) if (!string.IsNullOrWhiteSpace(searchTerm))
{ {
// Check cache for search results (only cache pure searches, not filtered searches) // Check cache for search results (only cache pure searches, not filtered searches)
@@ -205,7 +232,7 @@ public partial class JellyfinController
// Fall through to integrated search below // 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 else
{ {
_logger.LogDebug("Browse request with no filters, proxying to Jellyfin with full query string"); _logger.LogDebug("Browse request with no filters, proxying to Jellyfin with full query string");
@@ -546,44 +573,20 @@ public partial class JellyfinController
try try
{ {
// Return with PascalCase - use ContentResult to bypass JSON serialization issues var response = new JellyfinItemsResponse
var response = new
{ {
Items = pagedItems, Items = pagedItems,
TotalRecordCount = items.Count, TotalRecordCount = items.Count,
StartIndex = startIndex StartIndex = startIndex
}; };
var json = SerializeSearchResponseJson(response); return await WriteSearchItemsResponseAsync(
response,
// Cache search results in Redis using the configured search TTL. searchTerm,
if (!string.IsNullOrWhiteSpace(searchTerm) && effectiveArtistIds,
string.IsNullOrWhiteSpace(effectiveArtistIds) && searchCacheKey,
!string.IsNullOrWhiteSpace(searchCacheKey)) externalHasRequestedTypeResults,
{ cleanQuery,
if (externalHasRequestedTypeResults) includeItemTypes);
{
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");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -592,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, await _cache.SetStringAsync(searchCacheKey!, json, CacheExtensions.SearchResultsTTL);
DictionaryKeyPolicy = null _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> /// <summary>
@@ -693,12 +730,9 @@ public partial class JellyfinController
var cleanQuery = searchTerm.Trim().Trim('"'); var cleanQuery = searchTerm.Trim().Trim('"');
var requestedTypes = ParseItemTypes(includeItemTypes); var requestedTypes = ParseItemTypes(includeItemTypes);
var externalSearchLimits = GetExternalSearchLimits(requestedTypes, limit, includePlaylistsAsAlbums: false); var externalSearchLimits = GetExternalSearchLimits(requestedTypes, limit, includePlaylistsAsAlbums: false);
var includesSongs = requestedTypes == null || requestedTypes.Length == 0 || var includesSongs = requestedTypes == null || requestedTypes.Length == 0 || requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase); var includesAlbums = requestedTypes == null || requestedTypes.Length == 0 || requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase);
var includesAlbums = requestedTypes == null || requestedTypes.Length == 0 || var includesArtists = requestedTypes == null || requestedTypes.Length == 0 || requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase);
var includesArtists = requestedTypes == null || requestedTypes.Length == 0 ||
requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
_logger.LogInformation( _logger.LogInformation(
"SEARCH TRACE: hint limits for query '{Query}' => songs={SongLimit}, albums={AlbumLimit}, artists={ArtistLimit}", "SEARCH TRACE: hint limits for query '{Query}' => songs={SongLimit}, albums={AlbumLimit}, artists={ArtistLimit}",
@@ -809,8 +843,7 @@ public partial class JellyfinController
var includeSongs = requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase); var includeSongs = requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
var includeAlbums = requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase) || var includeAlbums = requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase) ||
(includePlaylistsAsAlbums && (includePlaylistsAsAlbums && requestedTypes.Contains("Playlist", StringComparer.OrdinalIgnoreCase));
requestedTypes.Contains("Playlist", StringComparer.OrdinalIgnoreCase));
var includeArtists = requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase); var includeArtists = requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
return ( return (
@@ -821,14 +854,210 @@ public partial class JellyfinController
private static IActionResult CreateEmptyItemsResponse(int startIndex) private static IActionResult CreateEmptyItemsResponse(int startIndex)
{ {
return new JsonResult(new return CreateItemsResponse([], 0, startIndex);
{
Items = Array.Empty<object>(),
TotalRecordCount = 0,
StartIndex = 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> /// <summary>
/// Merges two source queues without reordering either queue. /// Merges two source queues without reordering either queue.
/// At each step, compare only the current head from each source and dequeue the winner. /// 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, private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName,
string playlistId) 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) // 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 currentJellyfinSignature = await GetJellyfinPlaylistSignatureAsync(playlistId);
var cachedJellyfinSignature = await _cache.GetAsync<string>(jellyfinSignatureCacheKey); var cachedJellyfinSignature = await _cache.GetAsync<string>(jellyfinSignatureCacheKey);
@@ -66,7 +76,10 @@ public partial class JellyfinController
var requestNeedsGenreMetadata = RequestIncludesField("Genres"); var requestNeedsGenreMetadata = RequestIncludesField("Genres");
// Check Redis cache first for fast serving (only if Jellyfin playlist hasn't changed) // 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); var cachedItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(cacheKey);
if (cachedItems != null && cachedItems.Count > 0 && if (cachedItems != null && cachedItems.Count > 0 &&
@@ -110,7 +123,7 @@ public partial class JellyfinController
} }
// Check file cache as fallback // Check file cache as fallback
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName); var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName, playlistScopeUserId, playlistScopeId);
if (fileItems != null && fileItems.Count > 0 && if (fileItems != null && fileItems.Count > 0 &&
InjectedPlaylistItemHelper.ContainsSyntheticLocalItems(fileItems)) InjectedPlaylistItemHelper.ContainsSyntheticLocalItems(fileItems))
{ {
@@ -147,14 +160,63 @@ public partial class JellyfinController
} }
// Check for ordered matched tracks from SpotifyTrackMatchingService // 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); var orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
if (orderedTracks == null || orderedTracks.Count == 0) 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); 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}", _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!) // Get existing Jellyfin playlist items (RAW - don't convert!)
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results // CRITICAL: Must include UserId parameter or Jellyfin returns empty results
var userId = _settings.UserId;
if (string.IsNullOrEmpty(userId)) if (string.IsNullOrEmpty(userId))
{ {
_logger.LogError( _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 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 // 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) if (spotifyTracks.Count == 0)
{ {
_logger.LogWarning("Could not get Spotify playlist tracks for {Playlist}", spotifyPlaylistName); _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 // 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) // Also cache in Redis for fast serving (reuse the same cache key from top of method)
await _cache.SetAsync(cacheKey, finalItems, CacheExtensions.SpotifyPlaylistItemsTTL); await _cache.SetAsync(cacheKey, finalItems, CacheExtensions.SpotifyPlaylistItemsTTL);
@@ -916,7 +977,7 @@ public partial class JellyfinController
{ {
try try
{ {
var userId = _settings.UserId; var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
var playlistItemsUrl = $"Playlists/{playlistId}/Items?Fields=Id"; var playlistItemsUrl = $"Playlists/{playlistId}/Items?Fields=Id";
if (!string.IsNullOrEmpty(userId)) if (!string.IsNullOrEmpty(userId))
{ {
@@ -958,14 +1019,19 @@ public partial class JellyfinController
/// <summary> /// <summary>
/// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts. /// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts.
/// </summary> /// </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 try
{ {
var cacheDir = "/app/cache/spotify"; var cacheDir = "/app/cache/spotify";
Directory.CreateDirectory(cacheDir); 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 filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true }); var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
@@ -983,11 +1049,15 @@ public partial class JellyfinController
/// <summary> /// <summary>
/// Loads playlist items (raw Jellyfin JSON) from file cache. /// Loads playlist items (raw Jellyfin JSON) from file cache.
/// </summary> /// </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 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"); var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_items.json");
if (!System.IO.File.Exists(filePath)) if (!System.IO.File.Exists(filePath))
+82 -1
View File
@@ -34,15 +34,18 @@ public partial class JellyfinController : ControllerBase
private readonly SpotifyApiSettings _spotifyApiSettings; private readonly SpotifyApiSettings _spotifyApiSettings;
private readonly ScrobblingSettings _scrobblingSettings; private readonly ScrobblingSettings _scrobblingSettings;
private readonly IMusicMetadataService _metadataService; private readonly IMusicMetadataService _metadataService;
private readonly ExternalArtistAppearancesService _externalArtistAppearancesService;
private readonly ParallelMetadataService? _parallelMetadataService; private readonly ParallelMetadataService? _parallelMetadataService;
private readonly ILocalLibraryService _localLibraryService; private readonly ILocalLibraryService _localLibraryService;
private readonly IDownloadService _downloadService; private readonly IDownloadService _downloadService;
private readonly JellyfinResponseBuilder _responseBuilder; private readonly JellyfinResponseBuilder _responseBuilder;
private readonly JellyfinModelMapper _modelMapper; private readonly JellyfinModelMapper _modelMapper;
private readonly JellyfinProxyService _proxyService; private readonly JellyfinProxyService _proxyService;
private readonly JellyfinUserContextResolver _jellyfinUserContextResolver;
private readonly JellyfinSessionManager _sessionManager; private readonly JellyfinSessionManager _sessionManager;
private readonly PlaylistSyncService? _playlistSyncService; private readonly PlaylistSyncService? _playlistSyncService;
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher; private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
private readonly SpotifyTrackMatchingService? _spotifyTrackMatchingService;
private readonly SpotifyLyricsService? _spotifyLyricsService; private readonly SpotifyLyricsService? _spotifyLyricsService;
private readonly LyricsPlusService? _lyricsPlusService; private readonly LyricsPlusService? _lyricsPlusService;
private readonly LrclibService? _lrclibService; private readonly LrclibService? _lrclibService;
@@ -60,11 +63,13 @@ public partial class JellyfinController : ControllerBase
IOptions<SpotifyApiSettings> spotifyApiSettings, IOptions<SpotifyApiSettings> spotifyApiSettings,
IOptions<ScrobblingSettings> scrobblingSettings, IOptions<ScrobblingSettings> scrobblingSettings,
IMusicMetadataService metadataService, IMusicMetadataService metadataService,
ExternalArtistAppearancesService externalArtistAppearancesService,
ILocalLibraryService localLibraryService, ILocalLibraryService localLibraryService,
IDownloadService downloadService, IDownloadService downloadService,
JellyfinResponseBuilder responseBuilder, JellyfinResponseBuilder responseBuilder,
JellyfinModelMapper modelMapper, JellyfinModelMapper modelMapper,
JellyfinProxyService proxyService, JellyfinProxyService proxyService,
JellyfinUserContextResolver jellyfinUserContextResolver,
JellyfinSessionManager sessionManager, JellyfinSessionManager sessionManager,
OdesliService odesliService, OdesliService odesliService,
RedisCacheService cache, RedisCacheService cache,
@@ -73,6 +78,7 @@ public partial class JellyfinController : ControllerBase
ParallelMetadataService? parallelMetadataService = null, ParallelMetadataService? parallelMetadataService = null,
PlaylistSyncService? playlistSyncService = null, PlaylistSyncService? playlistSyncService = null,
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null, SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
SpotifyTrackMatchingService? spotifyTrackMatchingService = null,
SpotifyLyricsService? spotifyLyricsService = null, SpotifyLyricsService? spotifyLyricsService = null,
LyricsPlusService? lyricsPlusService = null, LyricsPlusService? lyricsPlusService = null,
LrclibService? lrclibService = null, LrclibService? lrclibService = null,
@@ -85,15 +91,18 @@ public partial class JellyfinController : ControllerBase
_spotifyApiSettings = spotifyApiSettings.Value; _spotifyApiSettings = spotifyApiSettings.Value;
_scrobblingSettings = scrobblingSettings.Value; _scrobblingSettings = scrobblingSettings.Value;
_metadataService = metadataService; _metadataService = metadataService;
_externalArtistAppearancesService = externalArtistAppearancesService;
_parallelMetadataService = parallelMetadataService; _parallelMetadataService = parallelMetadataService;
_localLibraryService = localLibraryService; _localLibraryService = localLibraryService;
_downloadService = downloadService; _downloadService = downloadService;
_responseBuilder = responseBuilder; _responseBuilder = responseBuilder;
_modelMapper = modelMapper; _modelMapper = modelMapper;
_proxyService = proxyService; _proxyService = proxyService;
_jellyfinUserContextResolver = jellyfinUserContextResolver;
_sessionManager = sessionManager; _sessionManager = sessionManager;
_playlistSyncService = playlistSyncService; _playlistSyncService = playlistSyncService;
_spotifyPlaylistFetcher = spotifyPlaylistFetcher; _spotifyPlaylistFetcher = spotifyPlaylistFetcher;
_spotifyTrackMatchingService = spotifyTrackMatchingService;
_spotifyLyricsService = spotifyLyricsService; _spotifyLyricsService = spotifyLyricsService;
_lyricsPlusService = lyricsPlusService; _lyricsPlusService = lyricsPlusService;
_lrclibService = lrclibService; _lrclibService = lrclibService;
@@ -290,6 +299,75 @@ public partial class JellyfinController : ControllerBase
return _responseBuilder.CreateItemsResponse(new List<Song>()); 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() private int GetRequestedStartIndex()
{ {
return int.TryParse(Request.Query["StartIndex"], out var startIndex) && startIndex > 0 return int.TryParse(Request.Query["StartIndex"], out var startIndex) && startIndex > 0
@@ -1748,7 +1826,10 @@ public partial class JellyfinController : ControllerBase
// Search through each playlist's matched tracks cache // Search through each playlist's matched tracks cache
foreach (var playlist in playlists) 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); var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(cacheKey);
if (matchedTracks == null || matchedTracks.Count == 0) 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.Admin;
using allstarr.Services; using allstarr.Services;
using allstarr.Filters; using allstarr.Filters;
using allstarr.Services.Jellyfin;
using System.Text.Json; using System.Text.Json;
namespace allstarr.Controllers; namespace allstarr.Controllers;
@@ -27,6 +28,7 @@ public class PlaylistController : ControllerBase
private readonly HttpClient _jellyfinHttpClient; private readonly HttpClient _jellyfinHttpClient;
private readonly AdminHelperService _helperService; private readonly AdminHelperService _helperService;
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private readonly JellyfinUserContextResolver _jellyfinUserContextResolver;
private const string CacheDirectory = "/app/cache/spotify"; private const string CacheDirectory = "/app/cache/spotify";
public PlaylistController( public PlaylistController(
@@ -39,6 +41,7 @@ public class PlaylistController : ControllerBase
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
AdminHelperService helperService, AdminHelperService helperService,
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
JellyfinUserContextResolver jellyfinUserContextResolver,
SpotifyTrackMatchingService? matchingService = null) SpotifyTrackMatchingService? matchingService = null)
{ {
_logger = logger; _logger = logger;
@@ -51,6 +54,23 @@ public class PlaylistController : ControllerBase
_jellyfinHttpClient = httpClientFactory.CreateClient(); _jellyfinHttpClient = httpClientFactory.CreateClient();
_helperService = helperService; _helperService = helperService;
_serviceProvider = serviceProvider; _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")] [HttpGet("playlists")]
@@ -149,7 +169,7 @@ public class PlaylistController : ControllerBase
{ {
try try
{ {
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name); var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
spotifyTrackCount = spotifyTracks.Count; spotifyTrackCount = spotifyTracks.Count;
playlistInfo["trackCount"] = spotifyTrackCount; playlistInfo["trackCount"] = spotifyTrackCount;
_logger.LogDebug("Fetched {Count} tracks from Spotify for playlist {Name}", spotifyTrackCount, config.Name); _logger.LogDebug("Fetched {Count} tracks from Spotify for playlist {Name}", spotifyTrackCount, config.Name);
@@ -167,7 +187,10 @@ public class PlaylistController : ControllerBase
try try
{ {
// Try to use the pre-built playlist cache // 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; List<Dictionary<string, object?>>? cachedPlaylistItems = null;
try try
@@ -239,7 +262,7 @@ public class PlaylistController : ControllerBase
else else
{ {
// No playlist cache - calculate from global mappings as fallback // 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 localCount = 0;
var externalCount = 0; var externalCount = 0;
var missingCount = 0; var missingCount = 0;
@@ -291,7 +314,7 @@ public class PlaylistController : ControllerBase
try try
{ {
// Jellyfin requires UserId parameter to fetch playlist items // 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 no user configured, try to get the first user
if (string.IsNullOrEmpty(userId)) if (string.IsNullOrEmpty(userId))
@@ -330,10 +353,13 @@ public class PlaylistController : ControllerBase
if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items)) if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items))
{ {
// Get Spotify tracks to match against // 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!) // 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; List<Dictionary<string, object?>>? cachedPlaylistItems = null;
try try
@@ -438,7 +464,10 @@ public class PlaylistController : ControllerBase
} }
// Get matched external tracks cache once // 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 matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
var matchedSpotifyIds = new HashSet<string>( var matchedSpotifyIds = new HashSet<string>(
matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>() matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
@@ -455,7 +484,11 @@ public class PlaylistController : ControllerBase
var hasExternalMapping = false; var hasExternalMapping = false;
// FIRST: Check for manual Jellyfin mapping // 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); var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId)) if (!string.IsNullOrEmpty(manualJellyfinId))
@@ -466,7 +499,11 @@ public class PlaylistController : ControllerBase
else else
{ {
// Check for external manual mapping // 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); var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
if (!string.IsNullOrEmpty(externalMappingJson)) if (!string.IsNullOrEmpty(externalMappingJson))
@@ -592,16 +629,22 @@ public class PlaylistController : ControllerBase
public async Task<IActionResult> GetPlaylistTracks(string name) public async Task<IActionResult> GetPlaylistTracks(string name)
{ {
var decodedName = Uri.UnescapeDataString(name); var decodedName = Uri.UnescapeDataString(name);
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
var playlistScopeUserId = playlistConfig?.UserId;
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
// Get Spotify tracks // Get Spotify tracks
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName); var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName, playlistScopeUserId, playlistConfig?.JellyfinId);
var tracksWithStatus = new List<object>(); var tracksWithStatus = new List<object>();
var matchedTracksBySpotifyId = new Dictionary<string, MatchedTrack>(StringComparer.OrdinalIgnoreCase); var matchedTracksBySpotifyId = new Dictionary<string, MatchedTrack>(StringComparer.OrdinalIgnoreCase);
try try
{ {
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName); var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey); var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
if (matchedTracks != null) if (matchedTracks != null)
@@ -627,7 +670,10 @@ public class PlaylistController : ControllerBase
// Use the pre-built playlist cache (same as GetPlaylists endpoint) // Use the pre-built playlist cache (same as GetPlaylists endpoint)
// This cache includes all matched tracks with proper provider IDs // 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; List<Dictionary<string, object?>>? cachedPlaylistItems = null;
try try
@@ -948,7 +994,11 @@ public class PlaylistController : ControllerBase
string? externalProvider = null; string? externalProvider = null;
// Check for manual Jellyfin mapping // 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); var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId)) if (!string.IsNullOrEmpty(manualJellyfinId))
@@ -958,7 +1008,11 @@ public class PlaylistController : ControllerBase
else else
{ {
// Check for external manual mapping // 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); var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
if (!string.IsNullOrEmpty(externalMappingJson)) if (!string.IsNullOrEmpty(externalMappingJson))
@@ -1071,10 +1125,16 @@ public class PlaylistController : ControllerBase
try try
{ {
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
var playlistScopeUserId = playlistConfig?.UserId;
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
await _playlistFetcher.RefreshPlaylistAsync(decodedName); await _playlistFetcher.RefreshPlaylistAsync(decodedName);
// Clear playlist stats cache first (so it gets recalculated with fresh data) // 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); await _cache.DeleteAsync(statsCacheKey);
// Then invalidate playlist summary cache (will rebuild with fresh stats) // Then invalidate playlist summary cache (will rebuild with fresh stats)
@@ -1109,18 +1169,28 @@ public class PlaylistController : ControllerBase
try 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 // 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); await _cache.DeleteAsync(jellyfinSignatureCacheKey);
_logger.LogDebug("Cleared Jellyfin signature cache to force change detection"); _logger.LogDebug("Cleared Jellyfin signature cache to force change detection");
// Clear the matched results cache to force re-matching // 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); await _cache.DeleteAsync(matchedTracksKey);
_logger.LogDebug("Cleared matched tracks cache"); _logger.LogDebug("Cleared matched tracks cache");
// Clear the playlist items cache // Clear the playlist items cache
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName); var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
await _cache.DeleteAsync(playlistItemsCacheKey); await _cache.DeleteAsync(playlistItemsCacheKey);
_logger.LogDebug("Cleared playlist items cache"); _logger.LogDebug("Cleared playlist items cache");
@@ -1131,7 +1201,10 @@ public class PlaylistController : ControllerBase
_helperService.InvalidatePlaylistSummaryCache(); _helperService.InvalidatePlaylistSummaryCache();
// Clear playlist stats cache to force recalculation from new mappings // 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); await _cache.DeleteAsync(statsCacheKey);
_logger.LogDebug("Cleared stats cache for {Name}", decodedName); _logger.LogDebug("Cleared stats cache for {Name}", decodedName);
@@ -1196,7 +1269,7 @@ public class PlaylistController : ControllerBase
try try
{ {
var userId = _jellyfinSettings.UserId; var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync();
// Build URL with UserId if available // Build URL with UserId if available
var url = $"{_jellyfinSettings.Url}/Items?searchTerm={Uri.EscapeDataString(query)}&includeItemTypes=Audio&recursive=true&limit=20"; var url = $"{_jellyfinSettings.Url}/Items?searchTerm={Uri.EscapeDataString(query)}&includeItemTypes=Audio&recursive=true&limit=20";
@@ -1328,7 +1401,7 @@ public class PlaylistController : ControllerBase
try try
{ {
var userId = _jellyfinSettings.UserId; var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync();
var url = $"{_jellyfinSettings.Url}/Items/{id}"; var url = $"{_jellyfinSettings.Url}/Items/{id}";
if (!string.IsNullOrEmpty(userId)) if (!string.IsNullOrEmpty(userId))
@@ -1424,13 +1497,20 @@ public class PlaylistController : ControllerBase
try try
{ {
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
var playlistScopeUserId = playlistConfig?.UserId;
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
string? normalizedProvider = null; string? normalizedProvider = null;
string? normalizedExternalId = null; string? normalizedExternalId = null;
if (hasJellyfinMapping) if (hasJellyfinMapping)
{ {
// Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent) // 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!); await _cache.SetAsync(mappingKey, request.JellyfinId!);
// Also save to file for persistence across restarts // Also save to file for persistence across restarts
@@ -1442,7 +1522,11 @@ public class PlaylistController : ControllerBase
else else
{ {
// Store external mapping in cache (NO EXPIRATION - manual mappings are permanent) // 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 normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
normalizedExternalId = NormalizeExternalTrackId(normalizedProvider, request.ExternalId!); normalizedExternalId = NormalizeExternalTrackId(normalizedProvider, request.ExternalId!);
var externalMapping = new { provider = normalizedProvider, id = normalizedExternalId }; var externalMapping = new { provider = normalizedProvider, id = normalizedExternalId };
@@ -1482,10 +1566,22 @@ public class PlaylistController : ControllerBase
} }
// Clear all related caches to force rebuild // Clear all related caches to force rebuild
var matchedCacheKey = $"spotify:matched:{decodedName}"; var matchedCacheKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName); decodedName,
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName); playlistScopeUserId,
var statsCacheKey = $"spotify:playlist:stats:{decodedName}"; 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(matchedCacheKey);
await _cache.DeleteAsync(orderedCacheKey); await _cache.DeleteAsync(orderedCacheKey);
@@ -357,9 +357,9 @@ public class SpotifyAdminController : ControllerBase
{ {
var keys = new[] var keys = new[]
{ {
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name), CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name), CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name) CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId)
}; };
foreach (var key in keys) foreach (var key in keys)
+26 -3
View File
@@ -79,6 +79,29 @@ public class TrackMetadataRequest
public int? DurationMs { get; set; } public int? DurationMs { get; set; }
} }
/// <summary> public class SquidWtfEndpointHealthResponse
/// Request model for updating configuration {
/// </summary> 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> /// <summary>
/// Gets the playlist configuration by name. /// Gets the playlist configuration by name.
/// </summary> /// </summary>
public SpotifyPlaylistConfig? GetPlaylistByName(string name) => public SpotifyPlaylistConfig? GetPlaylistByName(string name, string? userId = null, string? jellyfinPlaylistId = null)
Playlists.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); {
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> /// <summary>
/// Checks if a Jellyfin playlist ID is configured for Spotify import. /// Checks if a Jellyfin playlist ID is configured for Spotify import.
+4 -5
View File
@@ -528,6 +528,7 @@ else
// Business services - shared across backends // Business services - shared across backends
builder.Services.AddSingleton(squidWtfEndpointCatalog); builder.Services.AddSingleton(squidWtfEndpointCatalog);
builder.Services.AddMemoryCache(); // L1 in-memory tier for RedisCacheService
builder.Services.AddSingleton<RedisCacheService>(); builder.Services.AddSingleton<RedisCacheService>();
builder.Services.AddSingleton<FavoritesMigrationService>(); builder.Services.AddSingleton<FavoritesMigrationService>();
builder.Services.AddSingleton<OdesliService>(); builder.Services.AddSingleton<OdesliService>();
@@ -540,6 +541,8 @@ if (backendType == BackendType.Jellyfin)
// Jellyfin services // Jellyfin services
builder.Services.AddSingleton<JellyfinResponseBuilder>(); builder.Services.AddSingleton<JellyfinResponseBuilder>();
builder.Services.AddSingleton<JellyfinModelMapper>(); builder.Services.AddSingleton<JellyfinModelMapper>();
builder.Services.AddSingleton<ExternalArtistAppearancesService>();
builder.Services.AddScoped<JellyfinUserContextResolver>();
builder.Services.AddScoped<JellyfinProxyService>(); builder.Services.AddScoped<JellyfinProxyService>();
builder.Services.AddSingleton<JellyfinSessionManager>(); builder.Services.AddSingleton<JellyfinSessionManager>();
builder.Services.AddScoped<JellyfinAuthFilter>(); builder.Services.AddScoped<JellyfinAuthFilter>();
@@ -965,11 +968,7 @@ if (app.Environment.IsDevelopment())
app.UseSwaggerUI(); app.UseSwaggerUI();
} }
// The admin UI is documented and intended to be reachable directly over HTTP on port 5275. app.UseHttpsRedirection();
// Keep HTTPS redirection for non-admin traffic only.
app.UseWhen(
context => context.Connection.LocalPort != 5275,
branch => branch.UseHttpsRedirection());
// Serve static files only on admin port (5275) // Serve static files only on admin port (5275)
app.UseMiddleware<allstarr.Middleware.AdminNetworkAllowlistMiddleware>(); app.UseMiddleware<allstarr.Middleware.AdminNetworkAllowlistMiddleware>();
@@ -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.AspNetCore.Http;
using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Primitives;
using System.Text.RegularExpressions;
namespace allstarr.Services.Common; namespace allstarr.Services.Common;
@@ -9,6 +10,10 @@ namespace allstarr.Services.Common;
/// </summary> /// </summary>
public static class AuthHeaderHelper public static class AuthHeaderHelper
{ {
private static readonly Regex AuthParameterRegex = new(
@"(?<key>[A-Za-z0-9_-]+)\s*=\s*""(?<value>[^""]*)""",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
/// <summary> /// <summary>
/// Forwards authentication headers from HTTP request to HttpRequestMessage. /// Forwards authentication headers from HTTP request to HttpRequestMessage.
/// Handles both X-Emby-Authorization and Authorization headers. /// Handles both X-Emby-Authorization and Authorization headers.
@@ -99,17 +104,7 @@ public static class AuthHeaderHelper
/// </summary> /// </summary>
private static string? ExtractDeviceIdFromAuthString(string authValue) private static string? ExtractDeviceIdFromAuthString(string authValue)
{ {
var deviceIdMatch = System.Text.RegularExpressions.Regex.Match( return ExtractAuthParameter(authValue, "DeviceId");
authValue,
@"DeviceId=""([^""]+)""",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (deviceIdMatch.Success)
{
return deviceIdMatch.Groups[1].Value;
}
return null;
} }
/// <summary> /// <summary>
@@ -140,16 +135,95 @@ public static class AuthHeaderHelper
/// </summary> /// </summary>
private static string? ExtractClientNameFromAuthString(string authValue) private static string? ExtractClientNameFromAuthString(string authValue)
{ {
var clientMatch = System.Text.RegularExpressions.Regex.Match( return ExtractAuthParameter(authValue, "Client");
authValue, }
@"Client=""([^""]+)""",
System.Text.RegularExpressions.RegexOptions.IgnoreCase); /// <summary>
/// Extracts the authenticated Jellyfin access token from request headers.
if (clientMatch.Success) /// 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; return null;
} }
+44 -18
View File
@@ -67,34 +67,52 @@ public static class CacheKeyBuilder
#region Spotify Keys #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() public static string BuildSpotifyPlaylistStatsPattern()
@@ -102,19 +120,27 @@ public static class CacheKeyBuilder
return "spotify:playlist:stats:*"; 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) public static string BuildSpotifyGlobalMappingKey(string spotifyId)
+278 -26
View File
@@ -1,27 +1,57 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using allstarr.Models.Settings; using allstarr.Models.Settings;
using allstarr.Serialization;
using StackExchange.Redis; using StackExchange.Redis;
using System.Collections.Concurrent;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using System.Text.RegularExpressions;
namespace allstarr.Services.Common; namespace allstarr.Services.Common;
/// <summary> /// <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> /// </summary>
public class RedisCacheService 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 RedisSettings _settings;
private readonly ILogger<RedisCacheService> _logger; private readonly ILogger<RedisCacheService> _logger;
private readonly IMemoryCache _memoryCache;
private readonly ConcurrentDictionary<string, byte> _memoryKeys = new(StringComparer.Ordinal);
private IConnectionMultiplexer? _redis; private IConnectionMultiplexer? _redis;
private IDatabase? _db; private IDatabase? _db;
private readonly object _lock = new(); private readonly object _lock = new();
public RedisCacheService( public RedisCacheService(
IOptions<RedisSettings> settings, IOptions<RedisSettings> settings,
ILogger<RedisCacheService> logger) ILogger<RedisCacheService> logger,
IMemoryCache memoryCache)
{ {
_settings = settings.Value; _settings = settings.Value;
_logger = logger; _logger = logger;
_memoryCache = memoryCache;
if (_settings.Enabled) if (_settings.Enabled)
{ {
@@ -48,23 +78,145 @@ public class RedisCacheService
public bool IsEnabled => _settings.Enabled && _db != null; public bool IsEnabled => _settings.Enabled && _db != null;
/// <summary> /// <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> /// </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 try
{ {
// L2: Fall back to Redis
var value = await _db!.StringGetAsync(key); var value = await _db!.StringGetAsync(key);
if (value.HasValue) 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 else
{ {
_logger.LogDebug("Redis cache MISS: {Key}", key); _logger.LogDebug("Cache MISS: {Key}", key);
} }
return value; return value;
} }
@@ -77,15 +229,17 @@ public class RedisCacheService
/// <summary> /// <summary>
/// Gets a cached value and deserializes it. /// 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> /// </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); var json = await GetStringAsync(key);
if (string.IsNullOrEmpty(json)) return null; if (string.IsNullOrEmpty(json)) return null;
try try
{ {
return JsonSerializer.Deserialize<T>(json); return DeserializeWithFallback<T>(json, key);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -96,11 +250,20 @@ public class RedisCacheService
/// <summary> /// <summary>
/// Sets a cached value with TTL. /// Sets a cached value with TTL.
/// Writes to both L1 memory cache and L2 Redis.
/// </summary> /// </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 try
{ {
return await SetStringInternalAsync(key, value, expiry); return await SetStringInternalAsync(key, value, expiry);
@@ -197,12 +360,14 @@ public class RedisCacheService
/// <summary> /// <summary>
/// Sets a cached value by serializing it with TTL. /// 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> /// </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 try
{ {
var json = JsonSerializer.Serialize(value); var json = SerializeWithFallback(value, key);
return await SetStringAsync(key, json, expiry); return await SetStringAsync(key, json, expiry);
} }
catch (Exception ex) catch (Exception ex)
@@ -212,13 +377,80 @@ public class RedisCacheService
} }
} }
/// <summary> private T? DeserializeWithFallback<T>(string json, string key) where T : class
/// Deletes a cached value.
/// </summary>
public async Task<bool> DeleteAsync(string key)
{ {
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 try
{ {
return await _db!.KeyDeleteAsync(key); return await _db!.KeyDeleteAsync(key);
@@ -233,10 +465,20 @@ public class RedisCacheService
/// <summary> /// <summary>
/// Checks if a key exists. /// Checks if a key exists.
/// </summary> /// </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 try
{ {
return await _db!.KeyExistsAsync(key); return await _db!.KeyExistsAsync(key);
@@ -271,10 +513,16 @@ public class RedisCacheService
/// Deletes all keys matching a pattern (e.g., "search:*"). /// Deletes all keys matching a pattern (e.g., "search:*").
/// WARNING: Use with caution as this scans all keys. /// WARNING: Use with caution as this scans all keys.
/// </summary> /// </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 try
{ {
var server = _redis!.GetServer(_redis.GetEndPoints().First()); var server = _redis!.GetServer(_redis.GetEndPoints().First());
@@ -282,18 +530,22 @@ public class RedisCacheService
if (keys.Length == 0) if (keys.Length == 0)
{ {
_logger.LogDebug("No keys found matching pattern: {Pattern}", pattern); _logger.LogDebug("No Redis keys found matching pattern: {Pattern}", pattern);
return 0; return memoryDeleted;
} }
var deleted = await _db!.KeyDeleteAsync(keys); 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; return (int)deleted;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Redis DELETE BY PATTERN failed for pattern: {Pattern}", pattern); _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) private void LogEndpointFailure(string baseUrl, Exception ex, bool willRetry)
{ {
var message = BuildFailureSummary(ex); var message = BuildFailureSummary(ex);
var isTimeoutOrCancellation = ex is TaskCanceledException or OperationCanceledException;
var verb = isTimeoutOrCancellation ? "request timed out" : "request failed";
if (willRetry) if (willRetry)
{ {
_logger.LogWarning("{Service} request failed at {Endpoint}: {Error}. Trying next...", _logger.LogWarning("{Service} {Verb} at {Endpoint}: {Error}. Trying next...",
_serviceName, baseUrl, message); _serviceName, verb, baseUrl, message);
} }
else else
{ {
_logger.LogError("{Service} request failed at {Endpoint}: {Error}", _logger.LogError("{Service} {Verb} at {Endpoint}: {Error}",
_serviceName, baseUrl, message); _serviceName, verb, baseUrl, message);
} }
_logger.LogDebug(ex, "{Service} detailed failure for endpoint {Endpoint}", _logger.LogDebug(ex, "{Service} detailed failure for endpoint {Endpoint}",
@@ -466,6 +468,16 @@ public class RoundRobinFallbackHelper
return $"{statusCode}: {httpRequestException.StatusCode.Value}"; return $"{statusCode}: {httpRequestException.StatusCode.Value}";
} }
if (ex is TaskCanceledException)
{
return "Timed out";
}
if (ex is OperationCanceledException)
{
return "Canceled";
}
return ex.Message; 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();
}
}
@@ -14,8 +14,6 @@ public class VersionUpgradeRebuildService : IHostedService
private readonly SpotifyTrackMatchingService _matchingService; private readonly SpotifyTrackMatchingService _matchingService;
private readonly SpotifyImportSettings _spotifyImportSettings; private readonly SpotifyImportSettings _spotifyImportSettings;
private readonly ILogger<VersionUpgradeRebuildService> _logger; private readonly ILogger<VersionUpgradeRebuildService> _logger;
private CancellationTokenSource? _backgroundRebuildCts;
private Task? _backgroundRebuildTask;
public VersionUpgradeRebuildService( public VersionUpgradeRebuildService(
SpotifyTrackMatchingService matchingService, SpotifyTrackMatchingService matchingService,
@@ -55,12 +53,15 @@ public class VersionUpgradeRebuildService : IHostedService
} }
else else
{ {
_logger.LogInformation( _logger.LogInformation("Triggering full rebuild for all playlists after version upgrade");
"Scheduling full rebuild for all playlists in background after version upgrade"); try
{
_backgroundRebuildCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); await _matchingService.TriggerRebuildAllAsync();
_backgroundRebuildTask = RunBackgroundRebuildAsync(currentVersion, _backgroundRebuildCts.Token); }
return; catch (Exception ex)
{
_logger.LogError(ex, "Failed to trigger auto rebuild after version upgrade");
}
} }
} }
else else
@@ -75,51 +76,7 @@ public class VersionUpgradeRebuildService : IHostedService
public Task StopAsync(CancellationToken cancellationToken) public Task StopAsync(CancellationToken cancellationToken)
{ {
return StopBackgroundRebuildAsync(cancellationToken); return Task.CompletedTask;
}
private async Task RunBackgroundRebuildAsync(string currentVersion, CancellationToken cancellationToken)
{
try
{
_logger.LogInformation("Starting background full rebuild for all playlists after version upgrade");
await _matchingService.TriggerRebuildAllAsync(cancellationToken);
_logger.LogInformation("Background full rebuild after version upgrade completed");
await WriteCurrentVersionAsync(currentVersion, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
_logger.LogWarning("Background full rebuild after version upgrade was cancelled before completion");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to trigger auto rebuild after version upgrade");
await WriteCurrentVersionAsync(currentVersion, CancellationToken.None);
}
}
private async Task StopBackgroundRebuildAsync(CancellationToken cancellationToken)
{
if (_backgroundRebuildTask == null)
{
return;
}
try
{
_backgroundRebuildCts?.Cancel();
await _backgroundRebuildTask.WaitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
// Host shutdown is in progress or the background task observed cancellation.
}
finally
{
_backgroundRebuildCts?.Dispose();
_backgroundRebuildCts = null;
_backgroundRebuildTask = null;
}
} }
private async Task<string?> ReadPreviousVersionAsync(CancellationToken cancellationToken) private async Task<string?> ReadPreviousVersionAsync(CancellationToken cancellationToken)
@@ -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);
}
}
@@ -24,6 +24,7 @@ public class JellyfinProxyService
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly JellyfinSettings _settings; private readonly JellyfinSettings _settings;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly JellyfinUserContextResolver _userContextResolver;
private readonly ILogger<JellyfinProxyService> _logger; private readonly ILogger<JellyfinProxyService> _logger;
private readonly RedisCacheService _cache; private readonly RedisCacheService _cache;
private string? _cachedMusicLibraryId; private string? _cachedMusicLibraryId;
@@ -36,16 +37,35 @@ public class JellyfinProxyService
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IOptions<JellyfinSettings> settings, IOptions<JellyfinSettings> settings,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
JellyfinUserContextResolver userContextResolver,
ILogger<JellyfinProxyService> logger, ILogger<JellyfinProxyService> logger,
RedisCacheService cache) RedisCacheService cache)
{ {
_httpClient = httpClientFactory.CreateClient(HttpClientName); _httpClient = httpClientFactory.CreateClient(HttpClientName);
_settings = settings.Value; _settings = settings.Value;
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_userContextResolver = userContextResolver;
_logger = logger; _logger = logger;
_cache = cache; _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> /// <summary>
/// Gets the music library ID, auto-detecting it if not configured. /// Gets the music library ID, auto-detecting it if not configured.
/// </summary> /// </summary>
@@ -191,14 +211,10 @@ public class JellyfinProxyService
{ {
using var request = CreateClientGetRequest(url, clientHeaders, out var isBrowserStaticRequest, out var isPublicEndpoint); 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; 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 (!response.IsSuccessStatusCode)
{ {
if (!isBrowserStaticRequest && !isPublicEndpoint) if (!isBrowserStaticRequest && !isPublicEndpoint)
@@ -207,23 +223,22 @@ public class JellyfinProxyService
} }
// Try to parse error response to pass through to client // Try to parse error response to pass through to client
if (!string.IsNullOrWhiteSpace(content)) try
{ {
try await using var errorStream = await response.Content.ReadAsStreamAsync();
{ var errorDoc = await JsonDocument.ParseAsync(errorStream);
var errorDoc = JsonDocument.Parse(content); return (errorDoc, statusCode);
return (errorDoc, statusCode); }
} catch (JsonException)
catch {
{ // Not valid JSON, return null
// Not valid JSON, return null
}
} }
return (null, statusCode); 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( private HttpRequestMessage CreateClientGetRequest(
@@ -552,10 +567,7 @@ public class JellyfinProxyService
["fields"] = "PrimaryImageAspectRatio,MediaSources,Path,Genres,Studios,DateCreated,Overview,ProviderIds" ["fields"] = "PrimaryImageAspectRatio,MediaSources,Path,Genres,Studios,DateCreated,Overview,ProviderIds"
}; };
if (!string.IsNullOrEmpty(_settings.UserId)) await AddResolvedUserIdAsync(queryParams, clientHeaders);
{
queryParams["userId"] = _settings.UserId;
}
// Note: We don't force parentId here - let clients specify which library to search // 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 // The controller will detect music library searches and add external results
@@ -602,10 +614,7 @@ public class JellyfinProxyService
["fields"] = "PrimaryImageAspectRatio,MediaSources,Path,Genres,Studios,DateCreated,Overview,ProviderIds,ParentId" ["fields"] = "PrimaryImageAspectRatio,MediaSources,Path,Genres,Studios,DateCreated,Overview,ProviderIds,ParentId"
}; };
if (!string.IsNullOrEmpty(_settings.UserId)) await AddResolvedUserIdAsync(queryParams, clientHeaders);
{
queryParams["userId"] = _settings.UserId;
}
if (!string.IsNullOrEmpty(parentId)) if (!string.IsNullOrEmpty(parentId))
{ {
@@ -647,10 +656,7 @@ public class JellyfinProxyService
{ {
var queryParams = new Dictionary<string, string>(); var queryParams = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(_settings.UserId)) await AddResolvedUserIdAsync(queryParams, clientHeaders);
{
queryParams["userId"] = _settings.UserId;
}
return await GetJsonAsync($"Items/{itemId}", queryParams, clientHeaders); return await GetJsonAsync($"Items/{itemId}", queryParams, clientHeaders);
} }
@@ -669,10 +675,7 @@ public class JellyfinProxyService
["fields"] = "PrimaryImageAspectRatio,Genres,Overview" ["fields"] = "PrimaryImageAspectRatio,Genres,Overview"
}; };
if (!string.IsNullOrEmpty(_settings.UserId)) await AddResolvedUserIdAsync(queryParams, clientHeaders);
{
queryParams["userId"] = _settings.UserId;
}
if (!string.IsNullOrEmpty(searchTerm)) if (!string.IsNullOrEmpty(searchTerm))
{ {
@@ -699,10 +702,7 @@ public class JellyfinProxyService
{ {
var queryParams = new Dictionary<string, string>(); var queryParams = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(_settings.UserId)) await AddResolvedUserIdAsync(queryParams, clientHeaders);
{
queryParams["userId"] = _settings.UserId;
}
// Try to get by ID first // Try to get by ID first
if (Guid.TryParse(artistIdOrName, out _)) if (Guid.TryParse(artistIdOrName, out _))
@@ -893,10 +893,7 @@ public class JellyfinProxyService
try try
{ {
var queryParams = new Dictionary<string, string>(); var queryParams = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(_settings.UserId)) await AddResolvedUserIdAsync(queryParams);
{
queryParams["userId"] = _settings.UserId;
}
var (result, statusCode) = await GetJsonAsync("Library/MediaFolders", queryParams); var (result, statusCode) = await GetJsonAsync("Library/MediaFolders", queryParams);
if (result == null) if (result == null)
@@ -1017,12 +1014,12 @@ public class JellyfinProxyService
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); 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 statusCode = (int)response.StatusCode;
var content = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
var content = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Jellyfin internal request returned {StatusCode} for {Url}: {Content}", _logger.LogWarning("Jellyfin internal request returned {StatusCode} for {Url}: {Content}",
statusCode, url, content); statusCode, url, content);
return (null, statusCode); return (null, statusCode);
@@ -1030,12 +1027,13 @@ public class JellyfinProxyService
try try
{ {
var jsonDocument = JsonDocument.Parse(content); await using var stream = await response.Content.ReadAsStreamAsync();
var jsonDocument = await JsonDocument.ParseAsync(stream);
return (jsonDocument, statusCode); return (jsonDocument, statusCode);
} }
catch (JsonException ex) 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); return (null, statusCode);
} }
} }
@@ -1,10 +1,11 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Net.WebSockets; using System.Net.WebSockets;
using System.Text; using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using allstarr.Models.Jellyfin;
using allstarr.Models.Settings; using allstarr.Models.Settings;
using allstarr.Serialization;
namespace allstarr.Services.Jellyfin; namespace allstarr.Services.Jellyfin;
@@ -185,21 +186,21 @@ public class JellyfinSessionManager : IDisposable
/// </summary> /// </summary>
private async Task<bool> PostCapabilitiesAsync(IHeaderDictionary headers) private async Task<bool> PostCapabilitiesAsync(IHeaderDictionary headers)
{ {
var capabilities = new var capabilities = new JellyfinSessionCapabilitiesPayload
{ {
PlayableMediaTypes = new[] { "Audio" }, PlayableMediaTypes = ["Audio"],
SupportedCommands = new[] SupportedCommands =
{ [
"Play", "Play",
"Playstate", "Playstate",
"PlayNext" "PlayNext"
}, ],
SupportsMediaControl = true, SupportsMediaControl = true,
SupportsPersistentIdentifier = true, SupportsPersistentIdentifier = true,
SupportsSync = false SupportsSync = false
}; };
var json = JsonSerializer.Serialize(capabilities); var json = AllstarrJsonSerializer.Serialize(capabilities);
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", json, headers); var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", json, headers);
if (statusCode == 204 || statusCode == 200) 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) // Report playback stopped to Jellyfin if we have a playing item (for scrobbling)
if (!string.IsNullOrEmpty(session.LastPlayingItemId)) if (!string.IsNullOrEmpty(session.LastPlayingItemId))
{ {
var stopPayload = new var stopPayload = new JellyfinPlaybackStatePayload
{ {
ItemId = session.LastPlayingItemId, ItemId = session.LastPlayingItemId,
PositionTicks = session.LastPlayingPositionTicks ?? 0 PositionTicks = session.LastPlayingPositionTicks ?? 0
}; };
var stopJson = JsonSerializer.Serialize(stopPayload); var stopJson = AllstarrJsonSerializer.Serialize(stopPayload);
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers); await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers);
_logger.LogInformation("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})", _logger.LogInformation("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks); 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)}";
}
}
+20 -102
View File
@@ -1026,7 +1026,26 @@ public class SpotifyApiClient : IDisposable
continue; continue;
} }
var trackCount = TryGetSpotifyPlaylistItemCount(playlist); // Get track count if available - try multiple possible paths
var trackCount = 0;
if (playlist.TryGetProperty("content", out var content))
{
if (content.TryGetProperty("totalCount", out var totalTrackCount))
{
trackCount = totalTrackCount.GetInt32();
}
}
// Fallback: try attributes.itemCount
else if (playlist.TryGetProperty("attributes", out var attributes) &&
attributes.TryGetProperty("itemCount", out var itemCountProp))
{
trackCount = itemCountProp.GetInt32();
}
// Fallback: try totalCount directly
else if (playlist.TryGetProperty("totalCount", out var directTotalCount))
{
trackCount = directTotalCount.GetInt32();
}
// Log if we couldn't find track count for debugging // Log if we couldn't find track count for debugging
if (trackCount == 0) if (trackCount == 0)
@@ -1038,9 +1057,7 @@ public class SpotifyApiClient : IDisposable
// Get owner name // Get owner name
string? ownerName = null; string? ownerName = null;
if (playlist.TryGetProperty("ownerV2", out var ownerV2) && if (playlist.TryGetProperty("ownerV2", out var ownerV2) &&
ownerV2.ValueKind == JsonValueKind.Object &&
ownerV2.TryGetProperty("data", out var ownerData) && ownerV2.TryGetProperty("data", out var ownerData) &&
ownerData.ValueKind == JsonValueKind.Object &&
ownerData.TryGetProperty("username", out var ownerNameProp)) ownerData.TryGetProperty("username", out var ownerNameProp))
{ {
ownerName = ownerNameProp.GetString(); ownerName = ownerNameProp.GetString();
@@ -1049,14 +1066,11 @@ public class SpotifyApiClient : IDisposable
// Get image URL // Get image URL
string? imageUrl = null; string? imageUrl = null;
if (playlist.TryGetProperty("images", out var images) && if (playlist.TryGetProperty("images", out var images) &&
images.ValueKind == JsonValueKind.Object &&
images.TryGetProperty("items", out var imageItems) && images.TryGetProperty("items", out var imageItems) &&
imageItems.ValueKind == JsonValueKind.Array &&
imageItems.GetArrayLength() > 0) imageItems.GetArrayLength() > 0)
{ {
var firstImage = imageItems[0]; var firstImage = imageItems[0];
if (firstImage.TryGetProperty("sources", out var sources) && if (firstImage.TryGetProperty("sources", out var sources) &&
sources.ValueKind == JsonValueKind.Array &&
sources.GetArrayLength() > 0) sources.GetArrayLength() > 0)
{ {
var firstSource = sources[0]; var firstSource = sources[0];
@@ -1151,68 +1165,6 @@ public class SpotifyApiClient : IDisposable
return null; return null;
} }
private static int TryGetSpotifyPlaylistItemCount(JsonElement playlistElement)
{
if (playlistElement.TryGetProperty("content", out var content) &&
content.ValueKind == JsonValueKind.Object &&
content.TryGetProperty("totalCount", out var totalTrackCount) &&
TryParseSpotifyIntegerElement(totalTrackCount, out var contentCount))
{
return contentCount;
}
if (playlistElement.TryGetProperty("attributes", out var attributes))
{
if (attributes.ValueKind == JsonValueKind.Object &&
attributes.TryGetProperty("itemCount", out var itemCountProp) &&
TryParseSpotifyIntegerElement(itemCountProp, out var directAttributeCount))
{
return directAttributeCount;
}
if (attributes.ValueKind == JsonValueKind.Array)
{
foreach (var attribute in attributes.EnumerateArray())
{
if (attribute.ValueKind != JsonValueKind.Object ||
!attribute.TryGetProperty("key", out var keyProp) ||
keyProp.ValueKind != JsonValueKind.String ||
!attribute.TryGetProperty("value", out var valueProp))
{
continue;
}
var key = keyProp.GetString();
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
var normalizedKey = key.Replace("_", "", StringComparison.OrdinalIgnoreCase)
.Replace(":", "", StringComparison.OrdinalIgnoreCase);
if (!normalizedKey.Contains("itemcount", StringComparison.OrdinalIgnoreCase) &&
!normalizedKey.Contains("trackcount", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (TryParseSpotifyIntegerElement(valueProp, out var attributeCount))
{
return attributeCount;
}
}
}
}
if (playlistElement.TryGetProperty("totalCount", out var directTotalCount) &&
TryParseSpotifyIntegerElement(directTotalCount, out var totalCount))
{
return totalCount;
}
return 0;
}
private static DateTime? ParseSpotifyDateElement(JsonElement value) private static DateTime? ParseSpotifyDateElement(JsonElement value)
{ {
switch (value.ValueKind) switch (value.ValueKind)
@@ -1286,40 +1238,6 @@ public class SpotifyApiClient : IDisposable
return null; return null;
} }
private static bool TryParseSpotifyIntegerElement(JsonElement value, out int parsed)
{
switch (value.ValueKind)
{
case JsonValueKind.Number:
return value.TryGetInt32(out parsed);
case JsonValueKind.String:
return int.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed);
case JsonValueKind.Object:
if (value.TryGetProperty("value", out var nestedValue) &&
TryParseSpotifyIntegerElement(nestedValue, out parsed))
{
return true;
}
if (value.TryGetProperty("itemCount", out var itemCount) &&
TryParseSpotifyIntegerElement(itemCount, out parsed))
{
return true;
}
if (value.TryGetProperty("totalCount", out var totalCount) &&
TryParseSpotifyIntegerElement(totalCount, out parsed))
{
return true;
}
break;
}
parsed = 0;
return false;
}
private static DateTime? ParseSpotifyUnixTimestamp(long value) private static DateTime? ParseSpotifyUnixTimestamp(long value)
{ {
try try
@@ -29,7 +29,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
private readonly RedisCacheService _cache; private readonly RedisCacheService _cache;
// Track Spotify playlist IDs after discovery // Track Spotify playlist IDs after discovery
private readonly Dictionary<string, string> _playlistNameToSpotifyId = new(); private readonly Dictionary<string, string> _playlistScopeToSpotifyId = new();
public SpotifyPlaylistFetcher( public SpotifyPlaylistFetcher(
ILogger<SpotifyPlaylistFetcher> logger, ILogger<SpotifyPlaylistFetcher> logger,
@@ -55,10 +55,20 @@ public class SpotifyPlaylistFetcher : BackgroundService
/// </summary> /// </summary>
/// <param name="playlistName">Playlist name (e.g., "Release Radar", "Discover Weekly")</param> /// <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> /// <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 = SpotifyPlaylistScopeResolver.ResolveConfig(
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName); _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 // Try Redis cache first
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey); var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
@@ -124,14 +134,14 @@ public class SpotifyPlaylistFetcher : BackgroundService
try try
{ {
// Try to use cached or configured Spotify playlist ID // 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 // Check if we have a configured Spotify ID for this playlist
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.Id)) if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.Id))
{ {
// Use the configured Spotify playlist ID directly // Use the configured Spotify playlist ID directly
spotifyId = playlistConfig.Id; spotifyId = playlistConfig.Id;
_playlistNameToSpotifyId[playlistName] = spotifyId; _playlistScopeToSpotifyId[playlistScope] = spotifyId;
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId); _logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
} }
else else
@@ -150,7 +160,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
} }
spotifyId = exactMatch.SpotifyId; spotifyId = exactMatch.SpotifyId;
_playlistNameToSpotifyId[playlistName] = spotifyId; _playlistScopeToSpotifyId[playlistScope] = spotifyId;
_logger.LogInformation("Found Spotify playlist '{Name}' with ID: {Id}", playlistName, spotifyId); _logger.LogInformation("Found Spotify playlist '{Name}' with ID: {Id}", playlistName, spotifyId);
} }
} }
@@ -226,7 +236,8 @@ public class SpotifyPlaylistFetcher : BackgroundService
string playlistName, string playlistName,
HashSet<string> jellyfinTrackIds) 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 // Filter to only tracks not in Jellyfin, preserving order
return allTracks return allTracks
@@ -237,17 +248,30 @@ public class SpotifyPlaylistFetcher : BackgroundService
/// <summary> /// <summary>
/// Manual trigger to refresh a specific playlist. /// Manual trigger to refresh a specific playlist.
/// </summary> /// </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); _logger.LogInformation("Manual refresh triggered for playlist '{Name}'", playlistName);
// Clear cache to force refresh // 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); await _cache.DeleteAsync(cacheKey);
// Re-fetch // Re-fetch
await GetPlaylistTracksAsync(playlistName); await GetPlaylistTracksAsync(playlistName, playlistScopeUserId, playlistConfig?.JellyfinId ?? jellyfinPlaylistId);
await ClearPlaylistImageCacheAsync(playlistName); await ClearPlaylistImageCacheAsync(playlistName, userId, jellyfinPlaylistId);
} }
/// <summary> /// <summary>
@@ -259,13 +283,20 @@ public class SpotifyPlaylistFetcher : BackgroundService
foreach (var config in _spotifyImportSettings.Playlists) 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)) if (playlistConfig == null || string.IsNullOrWhiteSpace(playlistConfig.JellyfinId))
{ {
return; return;
@@ -331,7 +362,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
{ {
// Check each playlist to see if it needs refreshing based on cron schedule // Check each playlist to see if it needs refreshing based on cron schedule
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var needsRefresh = new List<string>(); var needsRefresh = new List<SpotifyPlaylistConfig>();
foreach (var config in _spotifyImportSettings.Playlists) foreach (var config in _spotifyImportSettings.Playlists)
{ {
@@ -342,7 +373,10 @@ public class SpotifyPlaylistFetcher : BackgroundService
var cron = CronExpression.Parse(schedule); var cron = CronExpression.Parse(schedule);
// Check if we have cached data // 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); var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
if (cached != null) if (cached != null)
@@ -352,7 +386,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
if (nextRun.HasValue && now >= nextRun.Value) 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}", _logger.LogInformation("Playlist '{Name}' needs refresh - last fetched {Age:F1}h ago, next run was {NextRun}",
config.Name, (now - cached.FetchedAt).TotalHours, nextRun.Value); config.Name, (now - cached.FetchedAt).TotalHours, nextRun.Value);
} }
@@ -360,7 +394,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
else else
{ {
// No cache, fetch it // No cache, fetch it
needsRefresh.Add(config.Name); needsRefresh.Add(config);
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -374,24 +408,24 @@ public class SpotifyPlaylistFetcher : BackgroundService
{ {
_logger.LogInformation("=== CRON TRIGGER: Fetching {Count} playlists ===", needsRefresh.Count); _logger.LogInformation("=== CRON TRIGGER: Fetching {Count} playlists ===", needsRefresh.Count);
foreach (var playlistName in needsRefresh) foreach (var config in needsRefresh)
{ {
if (stoppingToken.IsCancellationRequested) break; if (stoppingToken.IsCancellationRequested) break;
try try
{ {
await GetPlaylistTracksAsync(playlistName); await GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
// Rate limiting between playlists // 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); await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
} }
} }
catch (Exception ex) 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 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); _logger.LogDebug(" {Name}: {Count} tracks", config.Name, tracks.Count);
// Log sample of track order for debugging // Log sample of track order for debugging
@@ -1,6 +1,7 @@
using allstarr.Models.Domain; using allstarr.Models.Domain;
using allstarr.Models.Settings; using allstarr.Models.Settings;
using allstarr.Models.Spotify; using allstarr.Models.Spotify;
using allstarr.Services.Admin;
using allstarr.Services.Common; using allstarr.Services.Common;
using allstarr.Services.Jellyfin; using allstarr.Services.Jellyfin;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@@ -38,7 +39,6 @@ public class SpotifyTrackMatchingService : BackgroundService
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count) private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count)
private static readonly TimeSpan ExternalProviderSearchTimeout = TimeSpan.FromSeconds(30);
// Track last run time per playlist to prevent duplicate runs // Track last run time per playlist to prevent duplicate runs
private readonly Dictionary<string, DateTime> _lastRunTimes = new(); private readonly Dictionary<string, DateTime> _lastRunTimes = new();
@@ -73,6 +73,24 @@ public class SpotifyTrackMatchingService : BackgroundService
return true; 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) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
_logger.LogInformation("========================================"); _logger.LogInformation("========================================");
@@ -122,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. // Use a small grace window so we don't miss exact-minute cron runs when waking slightly late.
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var schedulerReference = now.AddMinutes(-1); 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) foreach (var playlist in _spotifySettings.Playlists)
{ {
@@ -135,7 +153,7 @@ public class SpotifyTrackMatchingService : BackgroundService
if (nextRun.HasValue) if (nextRun.HasValue)
{ {
nextRuns.Add((playlist.Name, nextRun.Value, cron)); nextRuns.Add((playlist, nextRun.Value, cron));
} }
else else
{ {
@@ -170,7 +188,7 @@ public class SpotifyTrackMatchingService : BackgroundService
var waitTime = nextPlaylist.NextRun - now; var waitTime = nextPlaylist.NextRun - now;
_logger.LogInformation("Next scheduled run: {Playlist} at {Time} UTC (in {Minutes:F1} minutes)", _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 maxWait = TimeSpan.FromHours(1);
var actualWait = waitTime > maxWait ? maxWait : waitTime; var actualWait = waitTime > maxWait ? maxWait : waitTime;
@@ -191,10 +209,10 @@ public class SpotifyTrackMatchingService : BackgroundService
break; break;
} }
_logger.LogInformation("→ Running scheduled rebuild for {Playlist}", due.PlaylistName); _logger.LogInformation("→ Running scheduled rebuild for {Playlist}", due.Playlist.Name);
var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync( var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync(
due.PlaylistName, due.Playlist,
stoppingToken, stoppingToken,
trigger: "cron"); trigger: "cron");
@@ -205,7 +223,7 @@ public class SpotifyTrackMatchingService : BackgroundService
} }
_logger.LogInformation("✓ Finished scheduled rebuild for {Playlist} - Next run at {NextRun} UTC", _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. // Avoid a tight loop if one or more due playlists were skipped by cooldown.
@@ -226,29 +244,24 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Rebuilds a single playlist from scratch (clears cache, fetches fresh data, re-matches). /// Rebuilds a single playlist from scratch (clears cache, fetches fresh data, re-matches).
/// Used by individual per-playlist rebuild actions. /// Used by individual per-playlist rebuild actions.
/// </summary> /// </summary>
private async Task RebuildSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken) private async Task RebuildSinglePlaylistAsync(SpotifyPlaylistConfig playlist, CancellationToken cancellationToken)
{ {
var playlist = _spotifySettings.Playlists var playlistScopeUserId = GetPlaylistScopeUserId(playlist);
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase)); var playlistScopeId = GetPlaylistScopeId(playlist);
var playlistName = playlist.Name;
if (playlist == null)
{
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
return;
}
_logger.LogInformation("Step 1/3: Clearing cache for {Playlist}", playlistName); _logger.LogInformation("Step 1/3: Clearing cache for {Playlist}", playlistName);
// Clear cache for this playlist (same as "Rebuild All Remote" button) // Clear cache for this playlist (same as "Rebuild All Remote" button)
var keysToDelete = new[] var keysToDelete = new[]
{ {
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name), CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name, playlistScopeUserId, playlistScopeId),
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name), CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name, playlistScopeUserId, playlistScopeId),
CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlist.Name), // Legacy key CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlist.Name, playlistScopeUserId, playlistScopeId),
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name), CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name, playlistScopeUserId, playlistScopeId),
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name), CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name, playlistScopeUserId, playlistScopeId),
CacheKeyBuilder.BuildSpotifyPlaylistOrderedKey(playlist.Name), CacheKeyBuilder.BuildSpotifyPlaylistOrderedKey(playlist.Name, playlistScopeUserId, playlistScopeId),
CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(playlist.Name) CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(playlist.Name, playlistScopeUserId, playlistScopeId)
}; };
foreach (var key in keysToDelete) foreach (var key in keysToDelete)
@@ -269,7 +282,7 @@ public class SpotifyTrackMatchingService : BackgroundService
if (playlistFetcher != null) if (playlistFetcher != null)
{ {
// Force refresh from Spotify (clears cache and re-fetches) // Force refresh from Spotify (clears cache and re-fetches)
await playlistFetcher.RefreshPlaylistAsync(playlist.Name); await playlistFetcher.RefreshPlaylistAsync(playlist.Name, playlistScopeUserId, playlist.JellyfinId);
} }
} }
@@ -281,13 +294,13 @@ public class SpotifyTrackMatchingService : BackgroundService
{ {
// Use new direct API mode with ISRC support // Use new direct API mode with ISRC support
await MatchPlaylistTracksWithIsrcAsync( await MatchPlaylistTracksWithIsrcAsync(
playlist.Name, playlistFetcher, metadataService, cancellationToken); playlist, playlistFetcher, metadataService, cancellationToken);
} }
else else
{ {
// Fall back to legacy mode // Fall back to legacy mode
await MatchPlaylistTracksLegacyAsync( await MatchPlaylistTracksLegacyAsync(
playlist.Name, metadataService, cancellationToken); playlist, metadataService, cancellationToken);
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -304,16 +317,9 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Matches tracks for a single playlist WITHOUT clearing cache or refreshing from Spotify. /// Matches tracks for a single playlist WITHOUT clearing cache or refreshing from Spotify.
/// Used for lightweight re-matching when only local library has changed. /// Used for lightweight re-matching when only local library has changed.
/// </summary> /// </summary>
private async Task MatchSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken) private async Task MatchSinglePlaylistAsync(SpotifyPlaylistConfig playlist, CancellationToken cancellationToken)
{ {
var playlist = _spotifySettings.Playlists var playlistName = playlist.Name;
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
if (playlist == null)
{
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
return;
}
using var scope = _serviceProvider.CreateScope(); using var scope = _serviceProvider.CreateScope();
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>(); var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
@@ -331,13 +337,13 @@ public class SpotifyTrackMatchingService : BackgroundService
{ {
// Use new direct API mode with ISRC support // Use new direct API mode with ISRC support
await MatchPlaylistTracksWithIsrcAsync( await MatchPlaylistTracksWithIsrcAsync(
playlist.Name, playlistFetcher, metadataService, cancellationToken); playlist, playlistFetcher, metadataService, cancellationToken);
} }
else else
{ {
// Fall back to legacy mode // Fall back to legacy mode
await MatchPlaylistTracksLegacyAsync( await MatchPlaylistTracksLegacyAsync(
playlist.Name, metadataService, cancellationToken); playlist, metadataService, cancellationToken);
} }
await ClearPlaylistImageCacheAsync(playlist); await ClearPlaylistImageCacheAsync(playlist);
@@ -366,27 +372,38 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Public method to trigger full rebuild for all playlists (called from "Rebuild All Remote" button). /// Public method to trigger full rebuild for all playlists (called from "Rebuild All Remote" button).
/// This clears caches, fetches fresh data, and re-matches everything immediately. /// This clears caches, fetches fresh data, and re-matches everything immediately.
/// </summary> /// </summary>
public async Task TriggerRebuildAllAsync(CancellationToken cancellationToken = default) public async Task TriggerRebuildAllAsync()
{ {
_logger.LogInformation("Full rebuild triggered for all playlists"); _logger.LogInformation("Manual full rebuild triggered for all playlists");
await RebuildAllPlaylistsAsync(cancellationToken); await RebuildAllPlaylistsAsync(CancellationToken.None);
} }
/// <summary> /// <summary>
/// Public method to trigger full rebuild for a single playlist (called from individual "Rebuild Remote" button). /// 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. /// This clears cache, fetches fresh data, and re-matches - same workflow as scheduled cron rebuilds for a playlist.
/// </summary> /// </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); _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( var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync(
playlistName, playlist,
CancellationToken.None, CancellationToken.None,
trigger: "manual"); trigger: "manual");
if (!rebuilt) 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 timeSinceLastRun = DateTime.UtcNow - lastRun;
var remaining = _minimumRunInterval - timeSinceLastRun; var remaining = _minimumRunInterval - timeSinceLastRun;
@@ -400,11 +417,12 @@ public class SpotifyTrackMatchingService : BackgroundService
} }
private async Task<bool> TryRunSinglePlaylistRebuildWithCooldownAsync( private async Task<bool> TryRunSinglePlaylistRebuildWithCooldownAsync(
string playlistName, SpotifyPlaylistConfig playlist,
CancellationToken cancellationToken, CancellationToken cancellationToken,
string trigger) 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; var timeSinceLastRun = DateTime.UtcNow - lastRun;
if (timeSinceLastRun < _minimumRunInterval) if (timeSinceLastRun < _minimumRunInterval)
@@ -412,15 +430,15 @@ public class SpotifyTrackMatchingService : BackgroundService
_logger.LogWarning( _logger.LogWarning(
"Skipping {Trigger} rebuild for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)", "Skipping {Trigger} rebuild for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
trigger, trigger,
playlistName, playlist.Name,
(int)timeSinceLastRun.TotalSeconds, (int)timeSinceLastRun.TotalSeconds,
(int)_minimumRunInterval.TotalSeconds); (int)_minimumRunInterval.TotalSeconds);
return false; return false;
} }
} }
await RebuildSinglePlaylistAsync(playlistName, cancellationToken); await RebuildSinglePlaylistAsync(playlist, cancellationToken);
_lastRunTimes[playlistName] = DateTime.UtcNow; _lastRunTimes[runKey] = DateTime.UtcNow;
return true; return true;
} }
@@ -440,14 +458,23 @@ public class SpotifyTrackMatchingService : BackgroundService
/// This bypasses cron schedules and runs immediately WITHOUT clearing cache or refreshing from Spotify. /// 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. /// Use this when only the local library has changed, not when Spotify playlist changed.
/// </summary> /// </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); _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 // Intentionally no cooldown here: this path should react immediately to
// local library changes and manual mapping updates without waiting for // local library changes and manual mapping updates without waiting for
// Spotify API cooldown windows. // Spotify API cooldown windows.
await MatchSinglePlaylistAsync(playlistName, CancellationToken.None); await MatchSinglePlaylistAsync(playlist, CancellationToken.None);
} }
private async Task RebuildAllPlaylistsAsync(CancellationToken cancellationToken) private async Task RebuildAllPlaylistsAsync(CancellationToken cancellationToken)
@@ -467,7 +494,7 @@ public class SpotifyTrackMatchingService : BackgroundService
try try
{ {
await RebuildSinglePlaylistAsync(playlist.Name, cancellationToken); await RebuildSinglePlaylistAsync(playlist, cancellationToken);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -495,7 +522,7 @@ public class SpotifyTrackMatchingService : BackgroundService
try try
{ {
await MatchSinglePlaylistAsync(playlist.Name, cancellationToken); await MatchSinglePlaylistAsync(playlist, cancellationToken);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -513,15 +540,25 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Uses GREEDY ASSIGNMENT to maximize total matches. /// Uses GREEDY ASSIGNMENT to maximize total matches.
/// </summary> /// </summary>
private async Task MatchPlaylistTracksWithIsrcAsync( private async Task MatchPlaylistTracksWithIsrcAsync(
string playlistName, SpotifyPlaylistConfig playlistConfig,
SpotifyPlaylistFetcher playlistFetcher, SpotifyPlaylistFetcher playlistFetcher,
IMusicMetadataService metadataService, IMusicMetadataService metadataService,
CancellationToken cancellationToken) 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 // 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) if (spotifyTracks.Count == 0)
{ {
_logger.LogWarning("No tracks found for {Playlist}, skipping matching", playlistName); _logger.LogWarning("No tracks found for {Playlist}, skipping matching", playlistName);
@@ -529,12 +566,10 @@ public class SpotifyTrackMatchingService : BackgroundService
} }
// Get the Jellyfin playlist ID to check which tracks already exist // 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(); HashSet<string> existingSpotifyIds = new();
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId)) if (!string.IsNullOrEmpty(playlist.JellyfinId))
{ {
// Get existing tracks from Jellyfin playlist to avoid re-matching // Get existing tracks from Jellyfin playlist to avoid re-matching
using var scope = _serviceProvider.CreateScope(); using var scope = _serviceProvider.CreateScope();
@@ -546,8 +581,9 @@ public class SpotifyTrackMatchingService : BackgroundService
try try
{ {
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results // CRITICAL: Must include UserId parameter or Jellyfin returns empty results
var userId = jellyfinSettings.UserId; var userId = playlist.UserId ?? jellyfinSettings.UserId;
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items"; var jellyfinPlaylistId = playlist.JellyfinId;
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items";
var queryParams = new Dictionary<string, string>(); var queryParams = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(userId)) if (!string.IsNullOrEmpty(userId))
{ {
@@ -629,10 +665,18 @@ public class SpotifyTrackMatchingService : BackgroundService
foreach (var track in tracksToMatch) foreach (var track in tracksToMatch)
{ {
// Check if this track has a manual mapping but isn't in the cached results // 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 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 externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
var hasManualMapping = !string.IsNullOrEmpty(manualMapping) || !string.IsNullOrEmpty(externalMappingJson); var hasManualMapping = !string.IsNullOrEmpty(manualMapping) || !string.IsNullOrEmpty(externalMappingJson);
@@ -660,7 +704,7 @@ public class SpotifyTrackMatchingService : BackgroundService
// PHASE 1: Get ALL Jellyfin tracks from the playlist (already injected by plugin) // PHASE 1: Get ALL Jellyfin tracks from the playlist (already injected by plugin)
var jellyfinTracks = new List<Song>(); var jellyfinTracks = new List<Song>();
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId)) if (!string.IsNullOrEmpty(playlist.JellyfinId))
{ {
using var scope = _serviceProvider.CreateScope(); using var scope = _serviceProvider.CreateScope();
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>(); var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
@@ -671,8 +715,9 @@ public class SpotifyTrackMatchingService : BackgroundService
{ {
try try
{ {
var userId = jellyfinSettings.UserId; var userId = playlist.UserId ?? jellyfinSettings.UserId;
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items"; var jellyfinPlaylistId = playlist.JellyfinId;
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items";
var queryParams = new Dictionary<string, string> { ["Fields"] = CachedPlaylistItemFields }; var queryParams = new Dictionary<string, string> { ["Fields"] = CachedPlaylistItemFields };
if (!string.IsNullOrEmpty(userId)) if (!string.IsNullOrEmpty(userId))
{ {
@@ -774,28 +819,11 @@ public class SpotifyTrackMatchingService : BackgroundService
if (cancellationToken.IsCancellationRequested) break; if (cancellationToken.IsCancellationRequested) break;
var batch = unmatchedSpotifyTracks.Skip(i).Take(BatchSize).ToList(); var batch = unmatchedSpotifyTracks.Skip(i).Take(BatchSize).ToList();
var batchStart = i + 1;
var batchEnd = i + batch.Count;
var batchStopwatch = System.Diagnostics.Stopwatch.StartNew();
_logger.LogInformation(
"Starting external matching batch for {Playlist}: tracks {Start}-{End}/{Total}",
playlistName,
batchStart,
batchEnd,
unmatchedSpotifyTracks.Count);
var batchTasks = batch.Select(async spotifyTrack => var batchTasks = batch.Select(async spotifyTrack =>
{ {
var primaryArtist = spotifyTrack.PrimaryArtist;
var trackStopwatch = System.Diagnostics.Stopwatch.StartNew();
try try
{ {
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(ExternalProviderSearchTimeout);
var trackCancellationToken = timeoutCts.Token;
var candidates = new List<(Song Song, double Score, string MatchType)>(); var candidates = new List<(Song Song, double Score, string MatchType)>();
// Check global external mapping first // Check global external mapping first
@@ -807,23 +835,12 @@ public class SpotifyTrackMatchingService : BackgroundService
if (!string.IsNullOrEmpty(globalMapping.ExternalProvider) && if (!string.IsNullOrEmpty(globalMapping.ExternalProvider) &&
!string.IsNullOrEmpty(globalMapping.ExternalId)) !string.IsNullOrEmpty(globalMapping.ExternalId))
{ {
mappedSong = await metadataService.GetSongAsync( mappedSong = await metadataService.GetSongAsync(globalMapping.ExternalProvider, globalMapping.ExternalId);
globalMapping.ExternalProvider,
globalMapping.ExternalId,
trackCancellationToken);
} }
if (mappedSong != null) if (mappedSong != null)
{ {
candidates.Add((mappedSong, 100.0, "global-mapping-external")); candidates.Add((mappedSong, 100.0, "global-mapping-external"));
trackStopwatch.Stop();
_logger.LogDebug(
"External candidate search finished for {Playlist} track #{Position}: {Title} by {Artist} in {ElapsedMs}ms using global mapping",
playlistName,
spotifyTrack.Position,
spotifyTrack.Title,
primaryArtist,
trackStopwatch.ElapsedMilliseconds);
return (spotifyTrack, candidates); return (spotifyTrack, candidates);
} }
} }
@@ -831,31 +848,10 @@ public class SpotifyTrackMatchingService : BackgroundService
// Try ISRC match // Try ISRC match
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc)) if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
{ {
try var isrcSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
if (isrcSong != null)
{ {
var isrcSong = await TryMatchByIsrcAsync( candidates.Add((isrcSong, 100.0, "isrc"));
spotifyTrack.Isrc,
metadataService,
trackCancellationToken);
if (isrcSong != null)
{
candidates.Add((isrcSong, 100.0, "isrc"));
}
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"ISRC lookup failed for {Playlist} track #{Position}: {Title} by {Artist}",
playlistName,
spotifyTrack.Position,
spotifyTrack.Title,
primaryArtist);
} }
} }
@@ -863,8 +859,7 @@ public class SpotifyTrackMatchingService : BackgroundService
var fuzzySongs = await TryMatchByFuzzyMultipleAsync( var fuzzySongs = await TryMatchByFuzzyMultipleAsync(
spotifyTrack.Title, spotifyTrack.Title,
spotifyTrack.Artists, spotifyTrack.Artists,
metadataService, metadataService);
trackCancellationToken);
foreach (var (song, score) in fuzzySongs) foreach (var (song, score) in fuzzySongs)
{ {
@@ -874,48 +869,16 @@ public class SpotifyTrackMatchingService : BackgroundService
} }
} }
trackStopwatch.Stop();
_logger.LogDebug(
"External candidate search finished for {Playlist} track #{Position}: {Title} by {Artist} in {ElapsedMs}ms with {CandidateCount} candidates",
playlistName,
spotifyTrack.Position,
spotifyTrack.Title,
primaryArtist,
trackStopwatch.ElapsedMilliseconds,
candidates.Count);
return (spotifyTrack, candidates); return (spotifyTrack, candidates);
} }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return (spotifyTrack, new List<(Song, double, string)>());
}
catch (OperationCanceledException)
{
_logger.LogWarning(
"External candidate search timed out for {Playlist} track #{Position}: {Title} by {Artist} after {TimeoutSeconds}s",
playlistName,
spotifyTrack.Position,
spotifyTrack.Title,
primaryArtist,
ExternalProviderSearchTimeout.TotalSeconds);
return (spotifyTrack, new List<(Song, double, string)>());
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError( _logger.LogError(ex, "Failed to match track: {Title}", spotifyTrack.Title);
ex,
"Failed to match track for {Playlist} track #{Position}: {Title} by {Artist}",
playlistName,
spotifyTrack.Position,
spotifyTrack.Title,
primaryArtist);
return (spotifyTrack, new List<(Song, double, string)>()); return (spotifyTrack, new List<(Song, double, string)>());
} }
}).ToList(); }).ToList();
var batchResults = await Task.WhenAll(batchTasks); var batchResults = await Task.WhenAll(batchTasks);
batchStopwatch.Stop();
foreach (var result in batchResults) foreach (var result in batchResults)
{ {
@@ -925,16 +888,6 @@ public class SpotifyTrackMatchingService : BackgroundService
} }
} }
var batchCandidateCount = batchResults.Sum(result => result.Item2.Count);
_logger.LogInformation(
"Finished external matching batch for {Playlist}: tracks {Start}-{End}/{Total} in {ElapsedMs}ms ({CandidateCount} candidates)",
playlistName,
batchStart,
batchEnd,
unmatchedSpotifyTracks.Count,
batchStopwatch.ElapsedMilliseconds,
batchCandidateCount);
if (i + BatchSize < unmatchedSpotifyTracks.Count) if (i + BatchSize < unmatchedSpotifyTracks.Count)
{ {
await Task.Delay(DelayBetweenSearchesMs, cancellationToken); await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
@@ -1035,19 +988,19 @@ public class SpotifyTrackMatchingService : BackgroundService
["missing"] = statsMissingCount ["missing"] = statsMissingCount
}; };
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(playlistName); var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
await _cache.SetAsync(statsCacheKey, stats, TimeSpan.FromMinutes(30)); await _cache.SetAsync(statsCacheKey, stats, TimeSpan.FromMinutes(30));
_logger.LogInformation("📊 Updated stats cache for {Playlist}: {Local} local, {External} external, {Missing} missing", _logger.LogInformation("📊 Updated stats cache for {Playlist}: {Local} local, {External} external, {Missing} missing",
playlistName, statsLocalCount, statsExternalCount, statsMissingCount); playlistName, statsLocalCount, statsExternalCount, statsMissingCount);
// Calculate cache expiration: until next cron run (not just cache duration from settings) // 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 var cacheExpiration = TimeSpan.FromHours(24); // Default 24 hours
if (playlist != null && !string.IsNullOrEmpty(playlist.SyncSchedule)) if (!string.IsNullOrEmpty(playlist.SyncSchedule))
{ {
try try
{ {
@@ -1074,10 +1027,13 @@ public class SpotifyTrackMatchingService : BackgroundService
await _cache.SetAsync(matchedTracksKey, matchedTracks, cacheExpiration); await _cache.SetAsync(matchedTracksKey, matchedTracks, cacheExpiration);
// Save matched tracks to file for persistence across restarts // 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 // 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(); var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
await _cache.SetAsync(legacyKey, legacySongs, cacheExpiration); await _cache.SetAsync(legacyKey, legacySongs, cacheExpiration);
@@ -1087,7 +1043,7 @@ public class SpotifyTrackMatchingService : BackgroundService
// Pre-build playlist items cache for instant serving // Pre-build playlist items cache for instant serving
// This is what makes the UI show all matched tracks at once // 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 else
{ {
@@ -1107,136 +1063,140 @@ public class SpotifyTrackMatchingService : BackgroundService
private async Task<List<(Song Song, double Score)>> TryMatchByFuzzyMultipleAsync( private async Task<List<(Song Song, double Score)>> TryMatchByFuzzyMultipleAsync(
string title, string title,
List<string> artists, List<string> artists,
IMusicMetadataService metadataService, IMusicMetadataService metadataService)
CancellationToken cancellationToken)
{ {
var primaryArtist = artists.FirstOrDefault() ?? ""; try
var titleStripped = FuzzyMatcher.StripDecorators(title);
var query = $"{titleStripped} {primaryArtist}";
var allCandidates = new List<(Song Song, double Score)>();
// STEP 1: Search LOCAL Jellyfin library FIRST
using var scope = _serviceProvider.CreateScope();
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
if (proxyService != null)
{ {
try var primaryArtist = artists.FirstOrDefault() ?? "";
var titleStripped = FuzzyMatcher.StripDecorators(title);
var query = $"{titleStripped} {primaryArtist}";
var allCandidates = new List<(Song Song, double Score)>();
// STEP 1: Search LOCAL Jellyfin library FIRST
using var scope = _serviceProvider.CreateScope();
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
if (proxyService != null)
{ {
// Search Jellyfin for local tracks try
var searchParams = new Dictionary<string, string>
{ {
["searchTerm"] = query, // Search Jellyfin for local tracks
["includeItemTypes"] = "Audio", var searchParams = new Dictionary<string, string>
["recursive"] = "true",
["limit"] = "10"
};
var (searchResponse, _) = await proxyService.GetJsonAsyncInternal("Items", searchParams);
if (searchResponse != null && searchResponse.RootElement.TryGetProperty("Items", out var items))
{
var localResults = new List<Song>();
foreach (var item in items.EnumerateArray())
{ {
var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() ?? "" : ""; ["searchTerm"] = query,
var songTitle = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : ""; ["includeItemTypes"] = "Audio",
var artist = ""; ["recursive"] = "true",
["limit"] = "10"
};
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) var (searchResponse, _) = await proxyService.GetJsonAsyncInternal("Items", searchParams);
if (searchResponse != null && searchResponse.RootElement.TryGetProperty("Items", out var items))
{
var localResults = new List<Song>();
foreach (var item in items.EnumerateArray())
{ {
artist = artistsEl[0].GetString() ?? ""; var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() ?? "" : "";
} var songTitle = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl)) var artist = "";
{
artist = albumArtistEl.GetString() ?? ""; if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{
artist = artistsEl[0].GetString() ?? "";
}
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
{
artist = albumArtistEl.GetString() ?? "";
}
localResults.Add(new Song
{
Id = id,
Title = songTitle,
Artist = artist,
IsLocal = true
});
} }
localResults.Add(new Song if (localResults.Count > 0)
{ {
Id = id, // Score local results
Title = songTitle, var scoredLocal = localResults
Artist = artist, .Select(song => new
IsLocal = true {
}); Song = song,
} TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
})
.Select(x => new
{
x.Song,
x.TitleScore,
x.ArtistScore,
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
})
.Where(x =>
x.TotalScore >= 40 ||
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
x.TitleScore >= 85)
.OrderByDescending(x => x.TotalScore)
.Select(x => (x.Song, x.TotalScore))
.ToList();
if (localResults.Count > 0) allCandidates.AddRange(scoredLocal);
{
// Score local results // If we found good local matches, return them (don't search external)
var scoredLocal = localResults if (scoredLocal.Any(x => x.TotalScore >= 70))
.Select(song => new
{ {
Song = song, _logger.LogDebug("Found {Count} local matches for '{Title}', skipping external search",
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title), scoredLocal.Count, title);
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors) return allCandidates;
}) }
.Select(x => new
{
x.Song,
x.TitleScore,
x.ArtistScore,
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
})
.Where(x =>
x.TotalScore >= 40 ||
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
x.TitleScore >= 85)
.OrderByDescending(x => x.TotalScore)
.Select(x => (x.Song, x.TotalScore))
.ToList();
allCandidates.AddRange(scoredLocal);
// If we found good local matches, return them (don't search external)
if (scoredLocal.Any(x => x.TotalScore >= 70))
{
_logger.LogDebug("Found {Count} local matches for '{Title}', skipping external search",
scoredLocal.Count, title);
return allCandidates;
} }
} }
} }
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to search local library for '{Title}'", title);
}
} }
catch (Exception ex)
// STEP 2: Only search EXTERNAL if no good local match found
var externalResults = await metadataService.SearchSongsAsync(query, limit: 10);
if (externalResults.Count > 0)
{ {
_logger.LogWarning(ex, "Failed to search local library for '{Title}'", title); var scoredExternal = externalResults
.Select(song => new
{
Song = song,
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
})
.Select(x => new
{
x.Song,
x.TitleScore,
x.ArtistScore,
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
})
.Where(x =>
x.TotalScore >= 40 ||
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
x.TitleScore >= 85)
.OrderByDescending(x => x.TotalScore)
.Select(x => (x.Song, x.TotalScore))
.ToList();
allCandidates.AddRange(scoredExternal);
} }
return allCandidates;
} }
catch
cancellationToken.ThrowIfCancellationRequested();
// STEP 2: Only search EXTERNAL if no good local match found
var externalResults = await metadataService.SearchSongsAsync(query, limit: 10, cancellationToken);
if (externalResults.Count > 0)
{ {
var scoredExternal = externalResults return new List<(Song, double)>();
.Select(song => new
{
Song = song,
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
})
.Select(x => new
{
x.Song,
x.TitleScore,
x.ArtistScore,
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
})
.Where(x =>
x.TotalScore >= 40 ||
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
x.TitleScore >= 85)
.OrderByDescending(x => x.TotalScore)
.Select(x => (x.Song, x.TotalScore))
.ToList();
allCandidates.AddRange(scoredExternal);
} }
return allCandidates;
} }
private double CalculateMatchScore(string jellyfinTitle, string jellyfinArtist, string spotifyTitle, string spotifyArtist) private double CalculateMatchScore(string jellyfinTitle, string jellyfinArtist, string spotifyTitle, string spotifyArtist)
@@ -1250,19 +1210,21 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Attempts to match a track by ISRC. /// Attempts to match a track by ISRC.
/// SEARCHES LOCAL FIRST, then external if no local match found. /// SEARCHES LOCAL FIRST, then external if no local match found.
/// </summary> /// </summary>
private async Task<Song?> TryMatchByIsrcAsync( private async Task<Song?> TryMatchByIsrcAsync(string isrc, IMusicMetadataService metadataService)
string isrc,
IMusicMetadataService metadataService,
CancellationToken cancellationToken)
{ {
// STEP 1: Search LOCAL Jellyfin library FIRST by ISRC try
// Note: Jellyfin doesn't have ISRC search, so we skip local ISRC search {
// Local tracks will be found via fuzzy matching instead // STEP 1: Search LOCAL Jellyfin library FIRST by ISRC
// Note: Jellyfin doesn't have ISRC search, so we skip local ISRC search
// Local tracks will be found via fuzzy matching instead
cancellationToken.ThrowIfCancellationRequested(); // STEP 2: Search EXTERNAL by ISRC
return await metadataService.FindSongByIsrcAsync(isrc);
// STEP 2: Search EXTERNAL by ISRC }
return await metadataService.FindSongByIsrcAsync(isrc, cancellationToken); catch
{
return null;
}
} }
/// <summary> /// <summary>
@@ -1352,12 +1314,21 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Legacy matching mode using MissingTrack from Jellyfin plugin. /// Legacy matching mode using MissingTrack from Jellyfin plugin.
/// </summary> /// </summary>
private async Task MatchPlaylistTracksLegacyAsync( private async Task MatchPlaylistTracksLegacyAsync(
string playlistName, SpotifyPlaylistConfig playlistConfig,
IMusicMetadataService metadataService, IMusicMetadataService metadataService,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var missingTracksKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName); var playlistName = playlistConfig.Name;
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlistName); 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 // Check if we already have matched tracks cached
var existingMatched = await _cache.GetAsync<List<Song>>(matchedTracksKey); var existingMatched = await _cache.GetAsync<List<Song>>(matchedTracksKey);
@@ -1464,6 +1435,10 @@ public class SpotifyTrackMatchingService : BackgroundService
{ {
try 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); _logger.LogDebug("🔨 Pre-building playlist items cache for {Playlist}...", playlistName);
if (string.IsNullOrEmpty(jellyfinPlaylistId)) if (string.IsNullOrEmpty(jellyfinPlaylistId))
@@ -1484,7 +1459,7 @@ public class SpotifyTrackMatchingService : BackgroundService
return; return;
} }
var userId = jellyfinSettings.UserId; var userId = playlistConfig?.UserId ?? jellyfinSettings.UserId;
if (string.IsNullOrEmpty(userId)) if (string.IsNullOrEmpty(userId))
{ {
_logger.LogError("No UserId configured, cannot pre-build playlist cache for {Playlist}", playlistName); _logger.LogError("No UserId configured, cannot pre-build playlist cache for {Playlist}", playlistName);
@@ -1560,7 +1535,11 @@ public class SpotifyTrackMatchingService : BackgroundService
string? matchedKey = null; string? matchedKey = null;
// FIRST: Check for manual Jellyfin mapping // 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); var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId)) if (!string.IsNullOrEmpty(manualJellyfinId))
@@ -1640,7 +1619,11 @@ public class SpotifyTrackMatchingService : BackgroundService
} }
// SECOND: Check for external manual mapping // 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); var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
if (!string.IsNullOrEmpty(externalMappingJson)) if (!string.IsNullOrEmpty(externalMappingJson))
@@ -1928,11 +1911,14 @@ public class SpotifyTrackMatchingService : BackgroundService
} }
// Save to Redis cache with same expiration as matched tracks (until next cron run) // 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); await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);
// Save to file cache for persistence // Save to file cache for persistence
await SavePlaylistItemsToFileAsync(playlistName, finalItems); await SavePlaylistItemsToFileAsync(playlistName, finalItems, playlistScopeUserId, playlistScopeId);
var manualMappingInfo = ""; var manualMappingInfo = "";
if (manualExternalCount > 0) if (manualExternalCount > 0)
@@ -1957,14 +1943,19 @@ public class SpotifyTrackMatchingService : BackgroundService
/// <summary> /// <summary>
/// Saves playlist items to file cache for persistence across restarts. /// Saves playlist items to file cache for persistence across restarts.
/// </summary> /// </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 try
{ {
var cacheDir = "/app/cache/spotify"; var cacheDir = "/app/cache/spotify";
Directory.CreateDirectory(cacheDir); 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 filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true }); var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
@@ -1981,14 +1972,19 @@ public class SpotifyTrackMatchingService : BackgroundService
/// <summary> /// <summary>
/// Saves matched tracks to file cache for persistence across restarts. /// Saves matched tracks to file cache for persistence across restarts.
/// </summary> /// </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 try
{ {
var cacheDir = "/app/cache/spotify"; var cacheDir = "/app/cache/spotify";
Directory.CreateDirectory(cacheDir); 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 filePath = Path.Combine(cacheDir, $"{safeName}_matched.json");
var json = JsonSerializer.Serialize(matchedTracks, new JsonSerializerOptions { WriteIndented = true }); var json = JsonSerializer.Serialize(matchedTracks, new JsonSerializerOptions { WriteIndented = true });
@@ -510,7 +510,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
await Task.WhenAll(songsTask, albumsTask, artistsTask); await Task.WhenAll(songsTask, albumsTask, artistsTask);
var temp = new SearchResult var temp = new SearchResult
{ {
Songs = await songsTask, Songs = await songsTask,
Albums = await albumsTask, Albums = await albumsTask,
+102 -74
View File
@@ -12,8 +12,8 @@
<!-- Restart Required Banner --> <!-- Restart Required Banner -->
<div class="restart-banner" id="restart-banner"> <div class="restart-banner" id="restart-banner">
⚠️ Configuration changed. Restart required to apply changes. ⚠️ Configuration changed. Restart required to apply changes.
<button data-action="restartContainer">Restart Allstarr</button> <button onclick="restartContainer()">Restart Allstarr</button>
<button data-action="dismissRestartBanner" <button onclick="dismissRestartBanner()"
style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button> style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button>
</div> </div>
@@ -42,57 +42,44 @@
</div> </div>
</div> </div>
<div class="container hidden" id="main-container"> <div class="container" id="main-container" style="display:none;">
<div class="app-shell"> <header>
<aside class="sidebar" aria-label="Admin navigation"> <h1>
<div class="sidebar-brand"> Allstarr <span class="version" id="version">Loading...</span>
<div class="sidebar-title">Allstarr</div> </h1>
<div class="sidebar-subtitle" id="sidebar-version">Loading...</div> <div class="header-actions">
<div class="auth-user" id="auth-user-display" style="display:none;">
Signed in as <strong id="auth-user-name">-</strong>
</div> </div>
<nav class="sidebar-nav"> <button id="auth-logout-btn" onclick="logoutAdminSession()" style="display:none;">Logout</button>
<button class="sidebar-link active" type="button" data-tab="dashboard">Dashboard</button> <div id="status-indicator">
<button class="sidebar-link" type="button" data-tab="jellyfin-playlists">Link Playlists</button> <span class="status-badge" id="spotify-status">
<button class="sidebar-link" type="button" data-tab="playlists">Injected Playlists</button> <span class="status-dot"></span>
<button class="sidebar-link" type="button" data-tab="kept">Kept Downloads</button> <span>Loading...</span>
<button class="sidebar-link" type="button" data-tab="scrobbling">Scrobbling</button> </span>
<button class="sidebar-link" type="button" data-tab="config">Configuration</button>
<button class="sidebar-link" type="button" data-tab="endpoints">API Analytics</button>
</nav>
<div class="sidebar-footer">
<div class="auth-user hidden" id="auth-user-display">
Signed in as <strong id="auth-user-name">-</strong>
</div>
<button id="auth-logout-btn" data-action="logoutAdminSession" class="hidden">Logout</button>
</div> </div>
</aside> </div>
</header>
<main class="app-main"> <div class="tabs">
<header class="app-header"> <div class="tab active" data-tab="dashboard">Dashboard</div>
<h1> <div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
Allstarr <span class="version" id="version">Loading...</span> <div class="tab" data-tab="playlists">Injected Playlists</div>
</h1> <div class="tab" data-tab="kept">Kept Downloads</div>
<div class="header-actions"> <div class="tab" data-tab="scrobbling">Scrobbling</div>
<div id="status-indicator"> <div class="tab" data-tab="config">Configuration</div>
<span class="status-badge" id="spotify-status"> <div class="tab" data-tab="endpoints">API Analytics</div>
<span class="status-dot"></span> </div>
<span>Loading...</span>
</span>
</div>
</div>
</header>
<div class="tabs top-tabs" aria-hidden="true">
<div class="tab active" data-tab="dashboard">Dashboard</div>
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
<div class="tab" data-tab="playlists">Injected Playlists</div>
<div class="tab" data-tab="kept">Kept Downloads</div>
<div class="tab" data-tab="scrobbling">Scrobbling</div>
<div class="tab" data-tab="config">Configuration</div>
<div class="tab" data-tab="endpoints">API Analytics</div>
</div>
<!-- Dashboard Tab --> <!-- Dashboard Tab -->
<div class="tab-content active" id="tab-dashboard"> <div class="tab-content active" id="tab-dashboard">
<div class="card" id="download-activity-card">
<h2>Live Download Queue</h2>
<div id="download-activity-list" class="download-queue-list">
<div class="empty-state">No active downloads</div>
</div>
</div>
<div class="grid"> <div class="grid">
<div class="card"> <div class="card">
<h2>Spotify API</h2> <h2>Spotify API</h2>
@@ -141,9 +128,9 @@
</h2> </h2>
<div id="dashboard-guidance" class="guidance-stack"></div> <div id="dashboard-guidance" class="guidance-stack"></div>
<div class="card-actions-row"> <div class="card-actions-row">
<button class="primary" data-action="refreshPlaylists">Refresh All Playlists</button> <button class="primary" onclick="refreshPlaylists()">Refresh All Playlists</button>
<button data-action="clearCache">Clear Cache</button> <button onclick="clearCache()">Clear Cache</button>
<button data-action="openAddPlaylist">Add Playlist</button> <button onclick="openAddPlaylist()">Add Playlist</button>
<button onclick="window.location.href='/spotify-mappings.html'">View Spotify Mappings</button> <button onclick="window.location.href='/spotify-mappings.html'">View Spotify Mappings</button>
</div> </div>
</div> </div>
@@ -158,7 +145,7 @@
<button onclick="fetchJellyfinPlaylists()">Refresh</button> <button onclick="fetchJellyfinPlaylists()">Refresh</button>
</div> </div>
</h2> </h2>
<p class="text-secondary mb-16"> <p style="color: var(--text-secondary); margin-bottom: 16px;">
Connect your Jellyfin playlists to Spotify playlists. Allstarr will automatically fill in missing Connect your Jellyfin playlists to Spotify playlists. Allstarr will automatically fill in missing
tracks from Spotify using your preferred music service (SquidWTF/Deezer/Qobuz). tracks from Spotify using your preferred music service (SquidWTF/Deezer/Qobuz).
<br><strong>Tip:</strong> Use the sp_dc cookie method for best results - it's simpler and more <br><strong>Tip:</strong> Use the sp_dc cookie method for best results - it's simpler and more
@@ -166,9 +153,10 @@
</p> </p>
<div id="jellyfin-guidance" class="guidance-stack"></div> <div id="jellyfin-guidance" class="guidance-stack"></div>
<div id="jellyfin-user-filter" class="flex-row-wrap mb-16"> <div id="jellyfin-user-filter" style="display: flex; gap: 16px; margin-bottom: 16px; flex-wrap: wrap;">
<div class="form-group jellyfin-user-form-group"> <div class="form-group" style="margin: 0; flex: 1; min-width: 200px;">
<label class="text-secondary">User</label> <label
style="display: block; margin-bottom: 4px; color: var(--text-secondary); font-size: 0.85rem;">User</label>
<select id="jellyfin-user-select" onchange="fetchJellyfinPlaylists()" <select id="jellyfin-user-select" onchange="fetchJellyfinPlaylists()"
style="width: 100%; padding: 8px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);"> style="width: 100%; padding: 8px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);">
<option value="">All Users</option> <option value="">All Users</option>
@@ -244,7 +232,7 @@
</div> </div>
</details> </details>
<p class="text-secondary mb-12"> <p style="color: var(--text-secondary); margin-bottom: 12px;">
These are the Spotify playlists currently being injected into Jellyfin with tracks from your music These are the Spotify playlists currently being injected into Jellyfin with tracks from your music
service. service.
</p> </p>
@@ -280,14 +268,15 @@
Manual mappings override automatic matching for external providers (SquidWTF, Deezer, Qobuz). For Manual mappings override automatic matching for external providers (SquidWTF, Deezer, Qobuz). For
local Jellyfin tracks, use the Spotify Import plugin instead. local Jellyfin tracks, use the Spotify Import plugin instead.
</p> </p>
<div id="mappings-summary" class="summary-box"> <div id="mappings-summary"
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
<div> <div>
<span class="summary-label">Total:</span> <span style="color: var(--text-secondary);">Total:</span>
<span class="summary-value" id="mappings-total">0</span> <span style="font-weight: 600; margin-left: 8px;" id="mappings-total">0</span>
</div> </div>
<div> <div>
<span class="summary-label">External:</span> <span style="color: var(--text-secondary);">External:</span>
<span class="summary-value success" <span style="font-weight: 600; margin-left: 8px; color: var(--success);"
id="mappings-external">0</span> id="mappings-external">0</span>
</div> </div>
</div> </div>
@@ -320,14 +309,15 @@
<button onclick="fetchMissingTracks()">Refresh</button> <button onclick="fetchMissingTracks()">Refresh</button>
</div> </div>
</h2> </h2>
<p class="text-secondary mb-12"> <p style="color: var(--text-secondary); margin-bottom: 12px;">
Tracks that couldn't be matched locally or externally. Map them manually to add them to your Tracks that couldn't be matched locally or externally. Map them manually to add them to your
playlists. playlists.
</p> </p>
<div id="missing-summary" class="summary-box"> <div id="missing-summary"
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
<div> <div>
<span class="summary-label">Total Missing:</span> <span style="color: var(--text-secondary);">Total Missing:</span>
<span class="summary-value warning" <span style="font-weight: 600; margin-left: 8px; color: var(--warning);"
id="missing-total">0</span> id="missing-total">0</span>
</div> </div>
</div> </div>
@@ -358,23 +348,23 @@
<h2> <h2>
Kept Downloads Kept Downloads
<div class="actions"> <div class="actions">
<button onclick="downloadAllKept()" class="primary">Download All</button> <button onclick="downloadAllKept()" style="background:var(--accent);border-color:var(--accent);">Download All</button>
<button onclick="deleteAllKept()" class="danger">Delete All</button>
<button onclick="fetchDownloads()">Refresh</button> <button onclick="fetchDownloads()">Refresh</button>
</div> </div>
</h2> </h2>
<p class="text-secondary mb-12"> <p style="color: var(--text-secondary); margin-bottom: 12px;">
Downloaded files stored permanently. Download individual tracks or download all as a zip archive. Downloaded files stored permanently. Download individual tracks or download all as a zip archive.
</p> </p>
<div id="downloads-summary" class="summary-box"> <div id="downloads-summary"
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
<div> <div>
<span class="summary-label">Total Files:</span> <span style="color: var(--text-secondary);">Total Files:</span>
<span class="summary-value accent" <span style="font-weight: 600; margin-left: 8px; color: var(--accent);"
id="downloads-count">0</span> id="downloads-count">0</span>
</div> </div>
<div> <div>
<span class="summary-label">Total Size:</span> <span style="color: var(--text-secondary);">Total Size:</span>
<span class="summary-value accent" id="downloads-size">0 <span style="font-weight: 600; margin-left: 8px; color: var(--accent);" id="downloads-size">0
B</span> B</span>
</div> </div>
</div> </div>
@@ -883,6 +873,46 @@
<!-- API Analytics Tab --> <!-- API Analytics Tab -->
<div class="tab-content" id="tab-endpoints"> <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"> <div class="card">
<h2> <h2>
API Endpoint Usage API Endpoint Usage
@@ -982,8 +1012,6 @@
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>. <a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
</p> </p>
</footer> </footer>
</main>
</div>
</div> </div>
<!-- Add Playlist Modal --> <!-- Add Playlist Modal -->
-84
View File
@@ -1,84 +0,0 @@
function toBoolean(value) {
if (value === true || value === false) {
return value;
}
const normalized = String(value ?? "")
.trim()
.toLowerCase();
return normalized === "true" || normalized === "1" || normalized === "yes";
}
function toNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function getActionArgs(el) {
if (!el || !el.dataset) {
return {};
}
// Convention:
// - data-action="foo"
// - data-arg-bar="baz" => { bar: "baz" }
const args = {};
for (const [key, value] of Object.entries(el.dataset)) {
if (!key.startsWith("arg")) continue;
const argName = key.slice(3);
if (!argName) continue;
const normalized =
argName.charAt(0).toLowerCase() + argName.slice(1);
args[normalized] = value;
}
return args;
}
export function initActionDispatcher({ root = document } = {}) {
const handlers = new Map();
function register(actionName, handler) {
if (!actionName || typeof handler !== "function") {
return;
}
handlers.set(actionName, handler);
}
async function dispatch(actionName, el, event = null) {
const handler = handlers.get(actionName);
const args = getActionArgs(el);
if (handler) {
return await handler({ el, event, args, toBoolean, toNumber });
}
// Transitional fallback: if a legacy window function exists, call it.
// This allows incremental conversion away from inline onclick.
const legacy = typeof window !== "undefined" ? window[actionName] : null;
if (typeof legacy === "function") {
const legacyArgs = args && Object.keys(args).length > 0 ? [args] : [];
return legacy(...legacyArgs);
}
console.warn(`No handler registered for action "${actionName}"`);
return null;
}
function bind() {
root.addEventListener("click", (event) => {
const trigger = event.target?.closest?.("[data-action]");
if (!trigger) return;
const actionName = trigger.getAttribute("data-action") || "";
if (!actionName) return;
event.preventDefault();
dispatch(actionName, trigger, event);
});
}
bind();
return { register, dispatch };
}
+10 -15
View File
@@ -124,14 +124,6 @@ export async function deleteDownload(path) {
); );
} }
export async function deleteAllDownloads() {
return requestJson(
"/api/admin/downloads/all",
{ method: "DELETE" },
"Failed to delete all downloads",
);
}
export async function fetchConfig() { export async function fetchConfig() {
return requestJson( return requestJson(
"/api/admin/config", "/api/admin/config",
@@ -152,15 +144,10 @@ export async function fetchJellyfinUsers() {
return requestOptionalJson("/api/admin/jellyfin/users"); return requestOptionalJson("/api/admin/jellyfin/users");
} }
export async function fetchJellyfinPlaylists(userId = null, includeStats = true) { export async function fetchJellyfinPlaylists(userId = null) {
let url = "/api/admin/jellyfin/playlists"; let url = "/api/admin/jellyfin/playlists";
const params = [];
if (userId) { if (userId) {
params.push("userId=" + encodeURIComponent(userId)); url += "?userId=" + encodeURIComponent(userId);
}
params.push("includeStats=" + String(Boolean(includeStats)));
if (params.length > 0) {
url += "?" + params.join("&");
} }
return requestJson(url, {}, "Failed to fetch Jellyfin playlists"); return requestJson(url, {}, "Failed to fetch Jellyfin playlists");
@@ -427,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() { export async function fetchScrobblingStatus() {
return requestJson( return requestJson(
"/api/admin/scrobbling/status", "/api/admin/scrobbling/status",
+42 -30
View File
@@ -1,4 +1,4 @@
import { escapeHtml, escapeJs, showToast, formatCookieAge } from "./utils.js"; import { escapeHtml, showToast, formatCookieAge } from "./utils.js";
import * as API from "./api.js"; import * as API from "./api.js";
import * as UI from "./ui.js"; import * as UI from "./ui.js";
import { renderCookieAge } from "./settings-editor.js"; import { renderCookieAge } from "./settings-editor.js";
@@ -15,7 +15,6 @@ let onCookieNeedsInit = async () => {};
let setCurrentConfigState = () => {}; let setCurrentConfigState = () => {};
let syncConfigUiExtras = () => {}; let syncConfigUiExtras = () => {};
let loadScrobblingConfig = () => {}; let loadScrobblingConfig = () => {};
let jellyfinPlaylistRequestToken = 0;
async function fetchStatus() { async function fetchStatus() {
try { try {
@@ -130,7 +129,6 @@ async function fetchMissingTracks() {
missing.forEach((t) => { missing.forEach((t) => {
missingTracks.push({ missingTracks.push({
playlist: playlist.name, playlist: playlist.name,
provider: t.externalProvider || t.provider || "squidwtf",
...t, ...t,
}); });
}); });
@@ -153,7 +151,6 @@ async function fetchMissingTracks() {
const artist = const artist =
t.artists && t.artists.length > 0 ? t.artists.join(", ") : ""; t.artists && t.artists.length > 0 ? t.artists.join(", ") : "";
const searchQuery = `${t.title} ${artist}`; const searchQuery = `${t.title} ${artist}`;
const provider = t.provider || "squidwtf";
const trackPosition = Number.isFinite(t.position) const trackPosition = Number.isFinite(t.position)
? Number(t.position) ? Number(t.position)
: 0; : 0;
@@ -166,7 +163,7 @@ async function fetchMissingTracks() {
<td class="mapping-actions-cell"> <td class="mapping-actions-cell">
<button class="map-action-btn map-action-search missing-track-search-btn" <button class="map-action-btn map-action-search missing-track-search-btn"
data-query="${escapeHtml(searchQuery)}" data-query="${escapeHtml(searchQuery)}"
data-provider="${escapeHtml(provider)}">🔍 Search</button> data-provider="squidwtf">🔍 Search</button>
<button class="map-action-btn map-action-local missing-track-local-btn" <button class="map-action-btn map-action-local missing-track-local-btn"
data-playlist="${escapeHtml(t.playlist)}" data-playlist="${escapeHtml(t.playlist)}"
data-position="${trackPosition}" data-position="${trackPosition}"
@@ -216,9 +213,9 @@ async function fetchDownloads() {
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td> <td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td> <td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
<td> <td>
<button data-action="downloadFile" data-arg-path="${escapeHtml(escapeJs(f.path))}" <button onclick="downloadFile('${escapeJs(f.path)}')"
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button> style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button>
<button data-action="deleteDownload" data-arg-path="${escapeHtml(escapeJs(f.path))}" <button onclick="deleteDownload('${escapeJs(f.path)}')"
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button> class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
</td> </td>
</tr> </tr>
@@ -248,28 +245,11 @@ async function fetchJellyfinPlaylists() {
'<tr><td colspan="4" class="loading"><span class="spinner"></span> Loading Jellyfin playlists...</td></tr>'; '<tr><td colspan="4" class="loading"><span class="spinner"></span> Loading Jellyfin playlists...</td></tr>';
try { try {
const requestToken = ++jellyfinPlaylistRequestToken;
const userId = isAdminSession() const userId = isAdminSession()
? document.getElementById("jellyfin-user-select")?.value ? document.getElementById("jellyfin-user-select")?.value
: null; : null;
const baseData = await API.fetchJellyfinPlaylists(userId, false); const data = await API.fetchJellyfinPlaylists(userId);
if (requestToken !== jellyfinPlaylistRequestToken) { UI.updateJellyfinPlaylistsUI(data);
return;
}
UI.updateJellyfinPlaylistsUI(baseData);
// Enrich counts after initial render so big accounts don't appear empty.
API.fetchJellyfinPlaylists(userId, true)
.then((statsData) => {
if (requestToken !== jellyfinPlaylistRequestToken) {
return;
}
UI.updateJellyfinPlaylistsUI(statsData);
})
.catch((err) => {
console.error("Failed to fetch Jellyfin playlist track stats:", err);
});
} catch (error) { } catch (error) {
console.error("Failed to fetch Jellyfin playlists:", error); console.error("Failed to fetch Jellyfin playlists:", error);
tbody.innerHTML = tbody.innerHTML =
@@ -320,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() { function startPlaylistAutoRefresh() {
if (playlistAutoRefreshInterval) { if (playlistAutoRefreshInterval) {
clearInterval(playlistAutoRefreshInterval); clearInterval(playlistAutoRefreshInterval);
@@ -366,10 +373,7 @@ function startDashboardRefresh() {
fetchPlaylists(); fetchPlaylists();
fetchTrackMappings(); fetchTrackMappings();
fetchMissingTracks(); fetchMissingTracks();
const keptTab = document.getElementById("tab-kept"); fetchDownloads();
if (keptTab && keptTab.classList.contains("active")) {
fetchDownloads();
}
const endpointsTab = document.getElementById("tab-endpoints"); const endpointsTab = document.getElementById("tab-endpoints");
if (endpointsTab && endpointsTab.classList.contains("active")) { if (endpointsTab && endpointsTab.classList.contains("active")) {
@@ -393,6 +397,11 @@ async function loadDashboardData() {
fetchEndpointUsage(), 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. // Ensure user filter defaults are populated before loading Link Playlists rows.
await fetchJellyfinUsers(); await fetchJellyfinUsers();
await fetchJellyfinPlaylists(); await fetchJellyfinPlaylists();
@@ -403,6 +412,7 @@ async function loadDashboardData() {
} }
startDashboardRefresh(); startDashboardRefresh();
startDownloadActivityStream();
} }
function startDownloadActivityStream() { function startDownloadActivityStream() {
@@ -551,6 +561,7 @@ export function initDashboardData(options) {
window.fetchJellyfinUsers = fetchJellyfinUsers; window.fetchJellyfinUsers = fetchJellyfinUsers;
window.fetchEndpointUsage = fetchEndpointUsage; window.fetchEndpointUsage = fetchEndpointUsage;
window.clearEndpointUsage = clearEndpointUsage; window.clearEndpointUsage = clearEndpointUsage;
window.fetchSquidWtfEndpointHealth = fetchSquidWtfEndpointHealth;
return { return {
stopDashboardRefresh, stopDashboardRefresh,
@@ -562,5 +573,6 @@ export function initDashboardData(options) {
fetchJellyfinPlaylists, fetchJellyfinPlaylists,
fetchConfig, fetchConfig,
fetchStatus, fetchStatus,
fetchSquidWtfEndpointHealth,
}; };
} }
+11 -32
View File
@@ -100,14 +100,14 @@ export async function viewTracks(name) {
const durationSeconds = Math.floor((t.durationMs || 0) / 1000); const durationSeconds = Math.floor((t.durationMs || 0) / 1000);
const externalSearchLink = const externalSearchLink =
t.isLocal === false && t.searchQuery && t.externalProvider t.isLocal === false && t.searchQuery && t.externalProvider
? `<br><small style="color:var(--accent)"><a href="#" data-action="searchProvider" data-arg-query="${escapeHtml(escapeJs(t.searchQuery))}" data-arg-provider="${escapeHtml(escapeJs(t.externalProvider))}" style="color:var(--accent);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>` ? `<br><small style="color:var(--accent)"><a href="#" onclick="searchProvider('${escapeJs(t.searchQuery)}', '${escapeJs(t.externalProvider)}'); return false;" style="color:var(--accent);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>`
: ""; : "";
const missingSearchLink = const missingSearchLink =
t.isLocal === null && t.searchQuery t.isLocal === null && t.searchQuery
? `<br><small style="color:var(--text-secondary)"><a href="#" data-action="searchProvider" data-arg-query="${escapeHtml(escapeJs(t.searchQuery))}" data-arg-provider="squidwtf" style="color:var(--text-secondary);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>` ? `<br><small style="color:var(--text-secondary)"><a href="#" onclick="searchProvider('${escapeJs(t.searchQuery)}', 'squidwtf'); return false;" style="color:var(--text-secondary);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>`
: ""; : "";
const lyricsMapButton = `<button class="small" data-action="openLyricsMap" data-arg-artist="${escapeHtml(escapeJs(firstArtist))}" data-arg-title="${escapeHtml(escapeJs(t.title))}" data-arg-album="${escapeHtml(escapeJs(t.album || ""))}" data-arg-duration-seconds="${durationSeconds}" style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">Map Lyrics ID</button>`; const lyricsMapButton = `<button class="small" onclick="openLyricsMap('${escapeJs(firstArtist)}', '${escapeJs(t.title)}', '${escapeJs(t.album || "")}', ${durationSeconds})" style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">Map Lyrics ID</button>`;
return ` return `
<div class="track-item" data-position="${t.position}"> <div class="track-item" data-position="${t.position}">
@@ -246,7 +246,7 @@ export async function searchJellyfinTracks() {
const artist = track.artist || ""; const artist = track.artist || "";
const album = track.album || ""; const album = track.album || "";
return ` return `
<div class="jellyfin-result" data-jellyfin-id="${escapeHtml(id)}" data-action="selectJellyfinTrack" data-arg-jellyfin-id="${escapeHtml(escapeJs(id))}"> <div class="jellyfin-result" data-jellyfin-id="${escapeHtml(id)}" onclick="selectJellyfinTrack('${escapeJs(id)}')">
<div> <div>
<strong>${escapeHtml(title)}</strong> <strong>${escapeHtml(title)}</strong>
<br> <br>
@@ -344,15 +344,7 @@ export async function searchExternalTracks() {
const externalUrl = track.url || ""; const externalUrl = track.url || "";
return ` return `
<div class="external-result" data-result-index="${index}" data-external-id="${escapeHtml(id)}" <div class="external-result" data-result-index="${index}" data-external-id="${escapeHtml(id)}" onclick="selectExternalTrack(${index}, '${escapeJs(id)}', '${escapeJs(title)}', '${escapeJs(artist)}', '${escapeJs(providerName)}', '${escapeJs(externalUrl)}')">
data-action="selectExternalTrack"
data-arg-result-index="${index}"
data-arg-external-id="${escapeHtml(escapeJs(id))}"
data-arg-title="${escapeHtml(escapeJs(title))}"
data-arg-artist="${escapeHtml(escapeJs(artist))}"
data-arg-provider="${escapeHtml(escapeJs(providerName))}"
data-arg-external-url="${escapeHtml(escapeJs(externalUrl))}"
>
<div> <div>
<strong>${escapeHtml(title)}</strong> <strong>${escapeHtml(title)}</strong>
<br> <br>
@@ -670,26 +662,13 @@ export async function saveLyricsMapping() {
// Search provider (open in new tab) // Search provider (open in new tab)
export async function searchProvider(query, provider) { export async function searchProvider(query, provider) {
try { try {
const normalizedProvider = (provider || "squidwtf").toLowerCase(); const data = await API.getSquidWTFBaseUrl();
let searchUrl = ""; const baseUrl = data.baseUrl; // Use the actual property name from API
const searchUrl = `${baseUrl}/music/search?q=${encodeURIComponent(query)}`;
if (normalizedProvider === "squidwtf" || normalizedProvider === "tidal") {
const data = await API.getSquidWTFBaseUrl();
const baseUrl = data.baseUrl;
searchUrl = `${baseUrl}/search/?s=${encodeURIComponent(query)}`;
} else if (normalizedProvider === "deezer") {
searchUrl = `https://www.deezer.com/search/${encodeURIComponent(query)}`;
} else if (normalizedProvider === "qobuz") {
searchUrl = `https://www.qobuz.com/search?query=${encodeURIComponent(query)}`;
} else {
const data = await API.getSquidWTFBaseUrl();
const baseUrl = data.baseUrl;
searchUrl = `${baseUrl}/search/?s=${encodeURIComponent(query)}`;
}
window.open(searchUrl, "_blank"); window.open(searchUrl, "_blank");
} catch (error) { } catch (error) {
console.error("Failed to open provider search:", error); console.error("Failed to get SquidWTF base URL:", error);
showToast("Failed to open provider search link", "warning"); // Fallback to first encoded URL (triton)
showToast("Failed to get SquidWTF URL, using fallback", "warning");
} }
} }
+38 -103
View File
@@ -34,13 +34,17 @@ import {
} from "./playlist-admin.js"; } from "./playlist-admin.js";
import { initScrobblingAdmin } from "./scrobbling-admin.js"; import { initScrobblingAdmin } from "./scrobbling-admin.js";
import { initAuthSession } from "./auth-session.js"; import { initAuthSession } from "./auth-session.js";
import { initActionDispatcher } from "./action-dispatcher.js";
import { initNavigationView } from "./views/navigation-view.js";
import { initScrobblingView } from "./views/scrobbling-view.js";
let cookieDateInitialized = false; let cookieDateInitialized = false;
let restartRequired = false; let restartRequired = false;
window.showToast = showToast;
window.escapeHtml = escapeHtml;
window.escapeJs = escapeJs;
window.openModal = openModal;
window.closeModal = closeModal;
window.capitalizeProvider = capitalizeProvider;
window.showRestartBanner = function () { window.showRestartBanner = function () {
restartRequired = true; restartRequired = true;
document.getElementById("restart-banner")?.classList.add("active"); document.getElementById("restart-banner")?.classList.add("active");
@@ -54,30 +58,17 @@ window.switchTab = function (tabName) {
document document
.querySelectorAll(".tab") .querySelectorAll(".tab")
.forEach((tab) => tab.classList.remove("active")); .forEach((tab) => tab.classList.remove("active"));
document
.querySelectorAll(".sidebar-link")
.forEach((link) => link.classList.remove("active"));
document document
.querySelectorAll(".tab-content") .querySelectorAll(".tab-content")
.forEach((content) => content.classList.remove("active")); .forEach((content) => content.classList.remove("active"));
const tab = document.querySelector(`.tab[data-tab="${tabName}"]`); const tab = document.querySelector(`.tab[data-tab="${tabName}"]`);
const sidebarLink = document.querySelector(
`.sidebar-link[data-tab="${tabName}"]`,
);
const content = document.getElementById(`tab-${tabName}`); const content = document.getElementById(`tab-${tabName}`);
if (tab && content) { if (tab && content) {
tab.classList.add("active"); tab.classList.add("active");
if (sidebarLink) {
sidebarLink.classList.add("active");
}
content.classList.add("active"); content.classList.add("active");
window.location.hash = tabName; window.location.hash = tabName;
if (tabName === "kept" && typeof window.fetchDownloads === "function") {
window.fetchDownloads();
}
} }
}; };
@@ -147,112 +138,56 @@ const authSession = initAuthSession({
}, },
}); });
window.viewTracks = viewTracks;
window.openManualMap = openManualMap; window.openManualMap = openManualMap;
window.openExternalMap = openExternalMap; window.openExternalMap = openExternalMap;
window.openMapToLocal = openManualMap; window.openMapToLocal = openManualMap;
window.openMapToExternal = openExternalMap; window.openMapToExternal = openExternalMap;
window.openModal = openModal;
window.closeModal = closeModal;
window.searchJellyfinTracks = searchJellyfinTracks; window.searchJellyfinTracks = searchJellyfinTracks;
window.selectJellyfinTrack = selectJellyfinTrack;
window.saveLocalMapping = saveLocalMapping; window.saveLocalMapping = saveLocalMapping;
window.saveManualMapping = saveManualMapping; window.saveManualMapping = saveManualMapping;
window.searchExternalTracks = searchExternalTracks; window.searchExternalTracks = searchExternalTracks;
window.searchProvider = searchProvider; window.selectExternalTrack = selectExternalTrack;
window.validateExternalMapping = validateExternalMapping; window.validateExternalMapping = validateExternalMapping;
window.openLyricsMap = openLyricsMap;
window.saveLyricsMapping = saveLyricsMapping; window.saveLyricsMapping = saveLyricsMapping;
// Note: viewTracks/selectExternalTrack/selectJellyfinTrack/openLyricsMap/searchProvider window.searchProvider = searchProvider;
// are now wired via the ActionDispatcher and no longer require window exports.
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
console.log("🚀 Allstarr Admin UI (Modular) loaded"); console.log("🚀 Allstarr Admin UI (Modular) loaded");
const dispatcher = initActionDispatcher({ root: document }); document.querySelectorAll(".tab").forEach((tab) => {
// Register a few core actions first; more will be migrated as inline tab.addEventListener("click", () => {
// onclick handlers are removed from HTML and generated markup. window.switchTab(tab.dataset.tab);
dispatcher.register("switchTab", ({ args }) => { });
const tab = args?.tab || args?.tabName;
if (tab) {
window.switchTab(tab);
}
}); });
dispatcher.register("logoutAdminSession", () => window.logoutAdminSession?.());
dispatcher.register("dismissRestartBanner", () =>
window.dismissRestartBanner?.(),
);
dispatcher.register("restartContainer", () => window.restartContainer?.());
dispatcher.register("refreshPlaylists", () => window.refreshPlaylists?.());
dispatcher.register("clearCache", () => window.clearCache?.());
dispatcher.register("openAddPlaylist", () => window.openAddPlaylist?.());
dispatcher.register("toggleRowMenu", ({ event, args }) =>
window.toggleRowMenu?.(event, args?.menuId),
);
dispatcher.register("toggleDetailsRow", ({ event, args }) =>
window.toggleDetailsRow?.(event, args?.detailsRowId),
);
dispatcher.register("viewTracks", ({ args }) => viewTracks(args?.playlistName));
dispatcher.register("refreshPlaylist", ({ args }) =>
window.refreshPlaylist?.(args?.playlistName),
);
dispatcher.register("matchPlaylistTracks", ({ args }) =>
window.matchPlaylistTracks?.(args?.playlistName),
);
dispatcher.register("clearPlaylistCache", ({ args }) =>
window.clearPlaylistCache?.(args?.playlistName),
);
dispatcher.register("editPlaylistSchedule", ({ args }) =>
window.editPlaylistSchedule?.(args?.playlistName, args?.syncSchedule),
);
dispatcher.register("removePlaylist", ({ args }) =>
window.removePlaylist?.(args?.playlistName),
);
dispatcher.register("openLinkPlaylist", ({ args }) =>
window.openLinkPlaylist?.(args?.jellyfinId, args?.jellyfinName),
);
dispatcher.register("unlinkPlaylist", ({ args }) =>
window.unlinkPlaylist?.(args?.jellyfinId, args?.jellyfinName),
);
dispatcher.register("fetchJellyfinPlaylists", () =>
window.fetchJellyfinPlaylists?.(),
);
dispatcher.register("searchProvider", ({ args }) =>
searchProvider(args?.query, args?.provider),
);
dispatcher.register("openLyricsMap", ({ args, toNumber }) =>
openLyricsMap(
args?.artist,
args?.title,
args?.album,
toNumber(args?.durationSeconds) ?? 0,
),
);
dispatcher.register("selectJellyfinTrack", ({ args }) =>
selectJellyfinTrack(args?.jellyfinId),
);
dispatcher.register("selectExternalTrack", ({ args, toNumber }) =>
selectExternalTrack(
toNumber(args?.resultIndex),
args?.externalId,
args?.title,
args?.artist,
args?.provider,
args?.externalUrl,
),
);
dispatcher.register("downloadFile", ({ args }) =>
window.downloadFile?.(args?.path),
);
dispatcher.register("deleteDownload", ({ args }) =>
window.deleteDownload?.(args?.path),
);
initNavigationView({ switchTab: window.switchTab }); const hash = window.location.hash.substring(1);
if (hash) {
window.switchTab(hash);
}
setupModalBackdropClose(); setupModalBackdropClose();
initScrobblingView({ const scrobblingTab = document.querySelector('.tab[data-tab="scrobbling"]');
isAuthenticated: () => authSession.isAuthenticated(), if (scrobblingTab) {
loadScrobblingConfig: () => window.loadScrobblingConfig?.(), scrobblingTab.addEventListener("click", () => {
}); if (authSession.isAuthenticated()) {
window.loadScrobblingConfig();
}
});
}
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(); authSession.bootstrapAuth();
}); });
+6 -89
View File
@@ -1,100 +1,17 @@
// Modal management // Modal management
const modalState = new Map();
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
function getModal(id) {
return document.getElementById(id);
}
function getFocusableElements(modal) {
return Array.from(modal.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
(el) => !el.hasAttribute("disabled") && !el.getAttribute("aria-hidden"),
);
}
function onModalKeyDown(event, modal) {
if (event.key === "Escape") {
event.preventDefault();
closeModal(modal.id);
return;
}
if (event.key !== "Tab") {
return;
}
const focusable = getFocusableElements(modal);
if (focusable.length === 0) {
event.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const isShift = event.shiftKey;
if (isShift && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!isShift && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
export function openModal(id) { export function openModal(id) {
const modal = getModal(id); document.getElementById(id).classList.add('active');
if (!modal) return;
const modalContent = modal.querySelector(".modal-content");
if (!modalContent) return;
const previousActive = document.activeElement;
modalState.set(id, { previousActive });
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.removeAttribute("aria-hidden");
modal.classList.add("active");
const keydownHandler = (event) => onModalKeyDown(event, modal);
modalState.set(id, { previousActive, keydownHandler });
modal.addEventListener("keydown", keydownHandler);
const focusable = getFocusableElements(modalContent);
if (focusable.length > 0) {
focusable[0].focus();
} else {
modalContent.setAttribute("tabindex", "-1");
modalContent.focus();
}
} }
export function closeModal(id) { export function closeModal(id) {
const modal = getModal(id); document.getElementById(id).classList.remove('active');
if (!modal) return;
modal.classList.remove("active");
modal.setAttribute("aria-hidden", "true");
const state = modalState.get(id);
if (state?.keydownHandler) {
modal.removeEventListener("keydown", state.keydownHandler);
}
if (state?.previousActive && typeof state.previousActive.focus === "function") {
state.previousActive.focus();
}
modalState.delete(id);
} }
export function setupModalBackdropClose() { export function setupModalBackdropClose() {
document.querySelectorAll(".modal").forEach((modal) => { document.querySelectorAll('.modal').forEach(modal => {
modal.setAttribute("aria-hidden", "true"); modal.addEventListener('click', e => {
modal.addEventListener("click", (e) => { if (e.target === modal) closeModal(modal.id);
if (e.target === modal) closeModal(modal.id); });
}); });
});
} }
-15
View File
@@ -77,20 +77,6 @@ function downloadAllKept() {
} }
} }
async function deleteAllKept() {
const result = await runAction({
confirmMessage:
"Delete ALL kept downloads?\n\nThis will permanently remove all kept audio files.",
task: () => API.deleteAllDownloads(),
success: (data) => data.message || "All kept downloads deleted",
error: (err) => err.message || "Failed to delete all kept downloads",
});
if (result) {
await fetchDownloads();
}
}
async function deleteDownload(path) { async function deleteDownload(path) {
const result = await runAction({ const result = await runAction({
confirmMessage: `Delete this file?\n\n${path}\n\nThis action cannot be undone.`, confirmMessage: `Delete this file?\n\n${path}\n\nThis action cannot be undone.`,
@@ -378,7 +364,6 @@ export function initOperations(options) {
window.deleteTrackMapping = deleteTrackMapping; window.deleteTrackMapping = deleteTrackMapping;
window.downloadFile = downloadFile; window.downloadFile = downloadFile;
window.downloadAllKept = downloadAllKept; window.downloadAllKept = downloadAllKept;
window.deleteAllKept = deleteAllKept;
window.deleteDownload = deleteDownload; window.deleteDownload = deleteDownload;
window.refreshPlaylists = refreshPlaylists; window.refreshPlaylists = refreshPlaylists;
window.refreshPlaylist = refreshPlaylist; window.refreshPlaylist = refreshPlaylist;
+1 -6
View File
@@ -70,12 +70,7 @@ async function openLinkPlaylist(jellyfinId, name) {
} }
try { try {
const response = await API.fetchSpotifyUserPlaylists(selectedUserId); spotifyUserPlaylists = await API.fetchSpotifyUserPlaylists(selectedUserId);
spotifyUserPlaylists = Array.isArray(response?.playlists)
? response.playlists
: Array.isArray(response)
? response
: [];
spotifyUserPlaylistsScopeUserId = selectedUserId; spotifyUserPlaylistsScopeUserId = selectedUserId;
const availablePlaylists = spotifyUserPlaylists.filter((p) => !p.isLinked); const availablePlaylists = spotifyUserPlaylists.filter((p) => !p.isLinked);
+134 -86
View File
@@ -3,8 +3,6 @@
import { escapeHtml, escapeJs, capitalizeProvider } from "./utils.js"; import { escapeHtml, escapeJs, capitalizeProvider } from "./utils.js";
let rowMenuHandlersBound = false; let rowMenuHandlersBound = false;
let tableRowHandlersBound = false;
const expandedInjectedPlaylistDetails = new Set();
function bindRowMenuHandlers() { function bindRowMenuHandlers() {
if (rowMenuHandlersBound) { if (rowMenuHandlersBound) {
@@ -18,41 +16,6 @@ function bindRowMenuHandlers() {
rowMenuHandlersBound = true; rowMenuHandlersBound = true;
} }
function bindTableRowHandlers() {
if (tableRowHandlersBound) {
return;
}
document.addEventListener("click", (event) => {
const detailsTrigger = event.target.closest?.(
"button.details-trigger[data-details-target]",
);
if (detailsTrigger) {
const target = detailsTrigger.getAttribute("data-details-target");
if (target) {
toggleDetailsRow(event, target);
}
return;
}
const row = event.target.closest?.("tr.compact-row[data-details-row]");
if (!row) {
return;
}
if (event.target.closest("button, a, .row-actions-menu")) {
return;
}
const detailsRowId = row.getAttribute("data-details-row");
if (detailsRowId) {
toggleDetailsRow(null, detailsRowId);
}
});
tableRowHandlersBound = true;
}
function closeAllRowMenus(exceptId = null) { function closeAllRowMenus(exceptId = null) {
document.querySelectorAll(".row-actions-menu.open").forEach((menu) => { document.querySelectorAll(".row-actions-menu.open").forEach((menu) => {
if (!exceptId || menu.id !== exceptId) { if (!exceptId || menu.id !== exceptId) {
@@ -119,18 +82,6 @@ function toggleDetailsRow(event, detailsRowId) {
); );
if (parentRow) { if (parentRow) {
parentRow.classList.toggle("expanded", isExpanded); parentRow.classList.toggle("expanded", isExpanded);
// Persist Injected Playlists details expansion across auto-refreshes.
if (parentRow.closest("#playlist-table-body")) {
const detailsKey = parentRow.getAttribute("data-details-key");
if (detailsKey) {
if (isExpanded) {
expandedInjectedPlaylistDetails.add(detailsKey);
} else {
expandedInjectedPlaylistDetails.delete(detailsKey);
}
}
}
} }
} }
@@ -232,15 +183,11 @@ if (typeof window !== "undefined") {
} }
bindRowMenuHandlers(); bindRowMenuHandlers();
bindTableRowHandlers();
export function updateStatusUI(data) { export function updateStatusUI(data) {
const versionEl = document.getElementById("version"); const versionEl = document.getElementById("version");
if (versionEl) versionEl.textContent = "v" + data.version; if (versionEl) versionEl.textContent = "v" + data.version;
const sidebarVersionEl = document.getElementById("sidebar-version");
if (sidebarVersionEl) sidebarVersionEl.textContent = "v" + data.version;
const backendTypeEl = document.getElementById("backend-type"); const backendTypeEl = document.getElementById("backend-type");
if (backendTypeEl) backendTypeEl.textContent = data.backendType; if (backendTypeEl) backendTypeEl.textContent = data.backendType;
@@ -324,7 +271,6 @@ export function updatePlaylistsUI(data) {
const playlists = data.playlists || []; const playlists = data.playlists || [];
if (playlists.length === 0) { if (playlists.length === 0) {
expandedInjectedPlaylistDetails.clear();
tbody.innerHTML = tbody.innerHTML =
'<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Link Playlists tab.</td></tr>'; '<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Link Playlists tab.</td></tr>';
renderGuidance("playlists-guidance", [ renderGuidance("playlists-guidance", [
@@ -383,12 +329,9 @@ export function updatePlaylistsUI(data) {
const summary = getPlaylistStatusSummary(playlist); const summary = getPlaylistStatusSummary(playlist);
const detailsRowId = `playlist-details-${index}`; const detailsRowId = `playlist-details-${index}`;
const menuId = `playlist-menu-${index}`; const menuId = `playlist-menu-${index}`;
const detailsKey = `${playlist.id || playlist.name || index}`;
const isExpanded = expandedInjectedPlaylistDetails.has(detailsKey);
const syncSchedule = playlist.syncSchedule || "0 8 * * *"; const syncSchedule = playlist.syncSchedule || "0 8 * * *";
const escapedPlaylistName = escapeHtml(playlist.name); const escapedPlaylistName = escapeJs(playlist.name);
const escapedSyncSchedule = escapeHtml(syncSchedule); const escapedSyncSchedule = escapeJs(syncSchedule);
const escapedDetailsKey = escapeHtml(detailsKey);
const breakdownBadges = [ const breakdownBadges = [
`<span class="status-pill neutral">${summary.localCount} Local</span>`, `<span class="status-pill neutral">${summary.localCount} Local</span>`,
@@ -402,7 +345,7 @@ export function updatePlaylistsUI(data) {
} }
return ` return `
<tr class="compact-row ${isExpanded ? "expanded" : ""}" data-details-row="${detailsRowId}" data-details-key="${escapedDetailsKey}"> <tr class="compact-row" data-details-row="${detailsRowId}" onclick="onCompactRowClick(event, '${detailsRowId}')">
<td> <td>
<div class="name-cell"> <div class="name-cell">
<strong>${escapeHtml(playlist.name)}</strong> <strong>${escapeHtml(playlist.name)}</strong>
@@ -415,23 +358,24 @@ export function updatePlaylistsUI(data) {
</td> </td>
<td><span class="status-pill ${summary.statusClass}">${summary.statusLabel}</span></td> <td><span class="status-pill ${summary.statusClass}">${summary.statusLabel}</span></td>
<td class="row-controls"> <td class="row-controls">
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="${isExpanded ? "true" : "false"}">${isExpanded ? "Hide" : "Details"}</button> <button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="false"
onclick="toggleDetailsRow(event, '${detailsRowId}')">Details</button>
<div class="row-actions-wrap"> <div class="row-actions-wrap">
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false" <button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false"
data-action="toggleRowMenu" data-arg-menu-id="${menuId}">...</button> onclick="toggleRowMenu(event, '${menuId}')">...</button>
<div class="row-actions-menu" id="${menuId}" role="menu"> <div class="row-actions-menu" id="${menuId}" role="menu">
<button data-action="viewTracks" data-arg-playlist-name="${escapedPlaylistName}">View Tracks</button> <button onclick="closeRowMenu(event, '${menuId}'); viewTracks('${escapedPlaylistName}')">View Tracks</button>
<button data-action="refreshPlaylist" data-arg-playlist-name="${escapedPlaylistName}">Refresh</button> <button onclick="closeRowMenu(event, '${menuId}'); refreshPlaylist('${escapedPlaylistName}')">Refresh</button>
<button data-action="matchPlaylistTracks" data-arg-playlist-name="${escapedPlaylistName}">Rematch</button> <button onclick="closeRowMenu(event, '${menuId}'); matchPlaylistTracks('${escapedPlaylistName}')">Rematch</button>
<button data-action="clearPlaylistCache" data-arg-playlist-name="${escapedPlaylistName}">Rebuild</button> <button onclick="closeRowMenu(event, '${menuId}'); clearPlaylistCache('${escapedPlaylistName}')">Rebuild</button>
<button data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit Schedule</button> <button onclick="closeRowMenu(event, '${menuId}'); editPlaylistSchedule('${escapedPlaylistName}', '${escapedSyncSchedule}')">Edit Schedule</button>
<hr> <hr>
<button class="danger-item" data-action="removePlaylist" data-arg-playlist-name="${escapedPlaylistName}">Remove Playlist</button> <button class="danger-item" onclick="closeRowMenu(event, '${menuId}'); removePlaylist('${escapedPlaylistName}')">Remove Playlist</button>
</div> </div>
</div> </div>
</td> </td>
</tr> </tr>
<tr id="${detailsRowId}" class="details-row" ${isExpanded ? "" : "hidden"}> <tr id="${detailsRowId}" class="details-row" hidden>
<td colspan="4"> <td colspan="4">
<div class="details-panel"> <div class="details-panel">
<div class="details-grid"> <div class="details-grid">
@@ -439,7 +383,7 @@ export function updatePlaylistsUI(data) {
<span class="detail-label">Sync Schedule</span> <span class="detail-label">Sync Schedule</span>
<span class="detail-value mono"> <span class="detail-value mono">
${escapeHtml(syncSchedule)} ${escapeHtml(syncSchedule)}
<button class="inline-action-link" data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit</button> <button class="inline-action-link" onclick="editPlaylistSchedule('${escapedPlaylistName}', '${escapedSyncSchedule}')">Edit</button>
</span> </span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
@@ -534,9 +478,9 @@ export function updateDownloadsUI(data) {
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td> <td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td> <td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
<td> <td>
<button data-action="downloadFile" data-arg-path="${escapeHtml(escapeJs(f.path))}" <button onclick="downloadFile('${escapeJs(f.path)}')"
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button> style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button>
<button data-action="deleteDownload" data-arg-path="${escapeHtml(escapeJs(f.path))}" <button onclick="deleteDownload('${escapeJs(f.path)}')"
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button> class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
</td> </td>
</tr> </tr>
@@ -690,27 +634,26 @@ export function updateJellyfinPlaylistsUI(data) {
.map((playlist, index) => { .map((playlist, index) => {
const detailsRowId = `jellyfin-details-${index}`; const detailsRowId = `jellyfin-details-${index}`;
const menuId = `jellyfin-menu-${index}`; const menuId = `jellyfin-menu-${index}`;
const statsPending = Boolean(playlist.statsPending);
const localCount = playlist.localTracks || 0; const localCount = playlist.localTracks || 0;
const externalCount = playlist.externalTracks || 0; const externalCount = playlist.externalTracks || 0;
const externalAvailable = playlist.externalAvailable || 0; const externalAvailable = playlist.externalAvailable || 0;
const escapedId = escapeHtml(playlist.id); const escapedId = escapeJs(playlist.id);
const escapedName = escapeHtml(playlist.name); const escapedName = escapeJs(playlist.name);
const statusClass = playlist.isConfigured ? "success" : "info"; const statusClass = playlist.isConfigured ? "success" : "info";
const statusLabel = playlist.isConfigured ? "Linked" : "Not Linked"; const statusLabel = playlist.isConfigured ? "Linked" : "Not Linked";
const actionButtons = playlist.isConfigured const actionButtons = playlist.isConfigured
? ` ? `
<button data-action="fetchJellyfinPlaylists">Refresh Row Data</button> <button onclick="closeRowMenu(event, '${menuId}'); fetchJellyfinPlaylists()">Refresh Row Data</button>
<button class="danger-item" data-action="unlinkPlaylist" data-arg-jellyfin-id="${escapedId}" data-arg-jellyfin-name="${escapedName}">Unlink from Spotify</button> <button class="danger-item" onclick="closeRowMenu(event, '${menuId}'); unlinkPlaylist('${escapedId}', '${escapedName}')">Unlink from Spotify</button>
` `
: ` : `
<button data-action="openLinkPlaylist" data-arg-jellyfin-id="${escapedId}" data-arg-jellyfin-name="${escapedName}">Link to Spotify</button> <button onclick="closeRowMenu(event, '${menuId}'); openLinkPlaylist('${escapedId}', '${escapedName}')">Link to Spotify</button>
<button data-action="fetchJellyfinPlaylists">Refresh Row Data</button> <button onclick="closeRowMenu(event, '${menuId}'); fetchJellyfinPlaylists()">Refresh Row Data</button>
`; `;
return ` return `
<tr class="compact-row" data-details-row="${detailsRowId}"> <tr class="compact-row" data-details-row="${detailsRowId}" onclick="onCompactRowClick(event, '${detailsRowId}')">
<td> <td>
<div class="name-cell"> <div class="name-cell">
<strong>${escapeHtml(playlist.name)}</strong> <strong>${escapeHtml(playlist.name)}</strong>
@@ -718,15 +661,16 @@ export function updateJellyfinPlaylistsUI(data) {
</div> </div>
</td> </td>
<td> <td>
<span class="track-count">${statsPending ? "..." : localCount + externalAvailable}</span> <span class="track-count">${localCount + externalAvailable}</span>
<div class="meta-text">${statsPending ? "Loading track stats..." : `L ${localCount} • E ${externalAvailable}/${externalCount}`}</div> <div class="meta-text">L ${localCount} • E ${externalAvailable}/${externalCount}</div>
</td> </td>
<td><span class="status-pill ${statusClass}">${statusLabel}</span></td> <td><span class="status-pill ${statusClass}">${statusLabel}</span></td>
<td class="row-controls"> <td class="row-controls">
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="false">Details</button> <button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="false"
onclick="toggleDetailsRow(event, '${detailsRowId}')">Details</button>
<div class="row-actions-wrap"> <div class="row-actions-wrap">
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false" <button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false"
data-action="toggleRowMenu" data-arg-menu-id="${menuId}">...</button> onclick="toggleRowMenu(event, '${menuId}')">...</button>
<div class="row-actions-menu" id="${menuId}" role="menu"> <div class="row-actions-menu" id="${menuId}" role="menu">
${actionButtons} ${actionButtons}
</div> </div>
@@ -739,11 +683,11 @@ export function updateJellyfinPlaylistsUI(data) {
<div class="details-grid"> <div class="details-grid">
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Local Tracks</span> <span class="detail-label">Local Tracks</span>
<span class="detail-value">${statsPending ? "..." : localCount}</span> <span class="detail-value">${localCount}</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">External Tracks</span> <span class="detail-label">External Tracks</span>
<span class="detail-value">${statsPending ? "Loading..." : `${externalAvailable}/${externalCount}`}</span> <span class="detail-value">${externalAvailable}/${externalCount}</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Linked Spotify ID</span> <span class="detail-label">Linked Spotify ID</span>
@@ -839,6 +783,110 @@ export function updateEndpointUsageUI(data) {
.join(""); .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) { export function showErrorState(message) {
const statusBadge = document.getElementById("spotify-status"); const statusBadge = document.getElementById("spotify-status");
if (statusBadge) { if (statusBadge) {
-4
View File
@@ -1,4 +0,0 @@
This folder contains small “view” modules for the admin UI.
Goal: keep `js/main.js` as orchestration only, while view modules encapsulate DOM wiring for each section.
@@ -1,22 +0,0 @@
export function initNavigationView({ switchTab } = {}) {
const doSwitch =
typeof switchTab === "function" ? switchTab : (tab) => window.switchTab?.(tab);
document.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
doSwitch(tab.dataset.tab);
});
});
document.querySelectorAll(".sidebar-link").forEach((link) => {
link.addEventListener("click", () => {
doSwitch(link.dataset.tab);
});
});
const hash = window.location.hash.substring(1);
if (hash) {
doSwitch(hash);
}
}
@@ -1,30 +0,0 @@
export function initScrobblingView({
isAuthenticated,
loadScrobblingConfig,
} = {}) {
const canLoad =
typeof isAuthenticated === "function" ? isAuthenticated : () => false;
const load =
typeof loadScrobblingConfig === "function"
? loadScrobblingConfig
: () => window.loadScrobblingConfig?.();
function onActivateScrobbling() {
if (canLoad()) {
load();
}
}
const scrobblingTab = document.querySelector('.tab[data-tab="scrobbling"]');
if (scrobblingTab) {
scrobblingTab.addEventListener("click", onActivateScrobbling);
}
const scrobblingSidebar = document.querySelector(
'.sidebar-link[data-tab="scrobbling"]',
);
if (scrobblingSidebar) {
scrobblingSidebar.addEventListener("click", onActivateScrobbling);
}
}
-1
View File
@@ -4,7 +4,6 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spotify Track Mappings - Allstarr</title> <title>Spotify Track Mappings - Allstarr</title>
<link rel="stylesheet" href="styles.css" />
<style> <style>
:root { :root {
--bg-primary: #0d1117; --bg-primary: #0d1117;
-22
View File
@@ -15,7 +15,6 @@ let localMapContext = null;
let localMapResults = []; let localMapResults = [];
let localMapSelectedIndex = -1; let localMapSelectedIndex = -1;
let externalMapContext = null; let externalMapContext = null;
const modalFocusState = new Map();
function showToast(message, type = "success", duration = 3000) { function showToast(message, type = "success", duration = 3000) {
const toast = document.createElement("div"); const toast = document.createElement("div");
@@ -248,26 +247,9 @@ function toggleModal(modalId, shouldOpen) {
} }
if (shouldOpen) { if (shouldOpen) {
const previousActive = document.activeElement;
modalFocusState.set(modalId, previousActive);
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.removeAttribute("aria-hidden");
modal.classList.add("active"); modal.classList.add("active");
const firstFocusable = modal.querySelector(
'button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])',
);
if (firstFocusable) {
firstFocusable.focus();
}
} else { } else {
modal.classList.remove("active"); modal.classList.remove("active");
modal.setAttribute("aria-hidden", "true");
const previousActive = modalFocusState.get(modalId);
if (previousActive && typeof previousActive.focus === "function") {
previousActive.focus();
}
modalFocusState.delete(modalId);
} }
} }
@@ -645,10 +627,6 @@ function initializeEventListeners() {
closeLocalMapModal(); closeLocalMapModal();
closeExternalMapModal(); closeExternalMapModal();
}); });
document.querySelectorAll(".modal-overlay").forEach((modal) => {
modal.setAttribute("aria-hidden", "true");
});
} }
// Initialize on page load // Initialize on page load
+78 -239
View File
@@ -97,107 +97,90 @@ body {
text-decoration: underline; 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 { .container {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 20px;
} }
.app-shell {
display: grid;
grid-template-columns: 260px 1fr;
gap: 18px;
align-items: start;
}
.sidebar {
position: sticky;
top: 16px;
border: 1px solid var(--border);
border-radius: 10px;
background: rgba(22, 27, 34, 0.8);
backdrop-filter: blur(8px);
padding: 14px;
max-height: calc(100vh - 32px);
overflow: auto;
}
.sidebar-brand {
padding-bottom: 12px;
margin-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.sidebar-title {
font-weight: 700;
font-size: 1.05rem;
letter-spacing: 0.2px;
}
.sidebar-subtitle {
margin-top: 2px;
color: var(--text-secondary);
font-size: 0.82rem;
font-family: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono",
monospace;
}
.sidebar-nav {
display: grid;
gap: 6px;
}
.sidebar-link {
width: 100%;
text-align: left;
padding: 9px 10px;
border-radius: 8px;
border: 1px solid transparent;
background: transparent;
color: var(--text-secondary);
}
.sidebar-link:hover {
background: rgba(33, 38, 45, 0.7);
color: var(--text-primary);
}
.sidebar-link.active {
background: rgba(88, 166, 255, 0.12);
border-color: rgba(88, 166, 255, 0.35);
color: #9ecbff;
}
.sidebar-footer {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--border);
display: grid;
gap: 10px;
}
.sidebar-footer button {
width: 100%;
}
.app-main {
min-width: 0;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0 16px;
border-bottom: 1px solid var(--border);
margin-bottom: 18px;
}
.top-tabs,
.tabs.top-tabs {
display: none !important;
}
.support-footer { .support-footer {
margin-top: 8px; margin-top: 8px;
padding: 20px 0 8px; padding: 20px 0 8px;
@@ -992,16 +975,6 @@ input::placeholder {
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.app-shell {
grid-template-columns: 1fr;
gap: 12px;
}
.sidebar {
position: static;
max-height: none;
}
.support-badge { .support-badge {
right: 12px; right: 12px;
bottom: 12px; bottom: 12px;
@@ -1024,140 +997,6 @@ input::placeholder {
display: block; display: block;
} }
/* Utility classes to reduce inline styles in index.html */
.hidden {
display: none;
}
.text-secondary {
color: var(--text-secondary);
}
.text-warning {
color: var(--warning);
}
.text-error {
color: var(--error);
}
.mb-12 {
margin-bottom: 12px;
}
.mb-16 {
margin-bottom: 16px;
}
.mt-8 {
margin-top: 8px;
}
.mt-12 {
margin-top: 12px;
}
.w-full {
width: 100%;
}
.flex-row-wrap {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.flex-row-wrap-8 {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.summary-box {
display: flex;
gap: 20px;
margin-bottom: 16px;
padding: 12px;
background: var(--bg-tertiary);
border-radius: 6px;
}
.summary-label {
color: var(--text-secondary);
}
.summary-value {
font-weight: 600;
margin-left: 8px;
}
.summary-value.success {
color: var(--success);
}
.summary-value.warning {
color: var(--warning);
}
.summary-value.accent {
color: var(--accent);
}
.callout {
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
}
.callout.warning {
background: rgba(245, 158, 11, 0.12);
border-color: var(--warning);
color: var(--text-secondary);
}
.callout.warning-strong {
background: rgba(255, 193, 7, 0.15);
border-color: #ffc107;
color: var(--text-primary);
}
.callout.danger {
background: rgba(248, 81, 73, 0.15);
border-color: var(--error);
color: var(--text-primary);
}
.pill-card {
background: var(--bg-tertiary);
padding: 16px;
border-radius: 8px;
}
.stats-grid-auto {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.max-h-600 {
max-height: 600px;
overflow-y: auto;
}
.jellyfin-user-form-group {
margin: 0;
flex: 1;
min-width: 200px;
}
.jellyfin-user-form-group label {
display: block;
margin-bottom: 4px;
font-size: 0.85rem;
}
.tracks-list { .tracks-list {
max-height: 400px; max-height: 400px;
overflow-y: auto; overflow-y: auto;