Compare commits

...

33 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
joshpatra 0a5b383526 v1.4.3: fixed .env restarting from Admin UI, re-release of prev ver 2026-03-25 16:11:27 -04:00
66 changed files with 5277 additions and 901 deletions
+1 -1
View File
@@ -73,7 +73,7 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,prefix=
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
+2 -2
View File
@@ -65,13 +65,13 @@ Allstarr includes a web UI for easy configuration and playlist management, acces
- `37i9dQZF1DXcBWIGoYBM5M` (just the ID)
- `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI)
- `https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M` (full URL)
4. **Restart** to apply changes (should be a banner)
4. **Restart Allstarr** to apply changes (should be a banner)
Then, proceeed to **Active Playlists**, which shows you which Spotify playlists are currently being monitored and filled with tracks, and lets you do a bunch of useful operations on them.
### Configuration Persistence
The web UI updates your `.env` file directly. Changes persist across container restarts, but require a restart to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`.
The web UI updates your `.env` file directly. Allstarr reloads that file on startup, so a normal container restart is enough for UI changes to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`.
There's an environment variable to modify this.
+35
View File
@@ -100,4 +100,39 @@ public class AuthHeaderHelperTests
Assert.Contains("Version=\"1.0\"", header);
Assert.Contains("Token=\"abc\"", header);
}
[Fact]
public void ExtractAccessToken_ShouldReadMediaBrowserToken()
{
var headers = new HeaderDictionary
{
["X-Emby-Authorization"] =
"MediaBrowser Client=\"Feishin\", Device=\"Desktop\", DeviceId=\"dev-123\", Version=\"1.0\", Token=\"abc\""
};
Assert.Equal("abc", AuthHeaderHelper.ExtractAccessToken(headers));
}
[Fact]
public void ExtractAccessToken_ShouldReadBearerToken()
{
var headers = new HeaderDictionary
{
["Authorization"] = "Bearer xyz"
};
Assert.Equal("xyz", AuthHeaderHelper.ExtractAccessToken(headers));
}
[Fact]
public void ExtractUserId_ShouldReadMediaBrowserUserId()
{
var headers = new HeaderDictionary
{
["X-Emby-Authorization"] =
"MediaBrowser Client=\"Feishin\", UserId=\"user-123\", Token=\"abc\""
};
Assert.Equal("user-123", AuthHeaderHelper.ExtractUserId(headers));
}
}
@@ -122,7 +122,8 @@ public class ConfigControllerAuthorizationTests
Enabled = false,
ConnectionString = "localhost:6379"
}),
redisLogger.Object);
redisLogger.Object,
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
var spotifyCookieLogger = new Mock<ILogger<SpotifySessionCookieService>>();
var spotifySessionCookieService = new SpotifySessionCookieService(
Options.Create(new SpotifyApiSettings()),
@@ -0,0 +1,220 @@
using System.Net;
using System.Net.Http;
using System.Text;
using allstarr.Controllers;
using allstarr.Models.Admin;
using allstarr.Models.Settings;
using allstarr.Services.Admin;
using allstarr.Services.Common;
using allstarr.Services.Spotify;
using allstarr.Services.SquidWTF;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
namespace allstarr.Tests;
public class DiagnosticsControllerTests
{
[Fact]
public async Task TestSquidWtfEndpoints_WithoutAdministratorSession_ReturnsForbidden()
{
var controller = CreateController(
CreateHttpContextWithSession(isAdmin: false),
_ => new HttpResponseMessage(HttpStatusCode.OK));
var result = await controller.TestSquidWtfEndpoints(CancellationToken.None);
var forbidden = Assert.IsType<ObjectResult>(result);
Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode);
}
[Fact]
public async Task TestSquidWtfEndpoints_ReturnsIndependentApiAndStreamingResults()
{
var controller = CreateController(
CreateHttpContextWithSession(isAdmin: true),
request =>
{
var uri = request.RequestUri!;
if (uri.Host == "node-one.example" && uri.AbsolutePath == "/search/")
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
"""
{"data":{"items":[{"id":227242909,"title":"Monica Lewinsky"}]}}
""",
Encoding.UTF8,
"application/json")
};
}
if (uri.Host == "node-one.example" && uri.AbsolutePath == "/track/")
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
"""
{"data":{"manifest":"ZmFrZS1tYW5pZmVzdA=="}}
""",
Encoding.UTF8,
"application/json")
};
}
if (uri.Host == "node-two.example" && uri.AbsolutePath == "/search/")
{
return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable);
}
if (uri.Host == "node-two.example" && uri.AbsolutePath == "/track/")
{
return new HttpResponseMessage(HttpStatusCode.GatewayTimeout);
}
throw new InvalidOperationException($"Unexpected request URI: {uri}");
});
var result = await controller.TestSquidWtfEndpoints(CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
var payload = Assert.IsType<SquidWtfEndpointHealthResponse>(ok.Value);
Assert.Equal(2, payload.TotalRows);
var nodeOne = Assert.Single(payload.Endpoints, e => e.Host == "node-one.example");
Assert.True(nodeOne.Api.Configured);
Assert.True(nodeOne.Api.IsUp);
Assert.Equal("up", nodeOne.Api.State);
Assert.Equal(200, nodeOne.Api.StatusCode);
Assert.True(nodeOne.Streaming.Configured);
Assert.True(nodeOne.Streaming.IsUp);
Assert.Equal("up", nodeOne.Streaming.State);
Assert.Equal(200, nodeOne.Streaming.StatusCode);
var nodeTwo = Assert.Single(payload.Endpoints, e => e.Host == "node-two.example");
Assert.True(nodeTwo.Api.Configured);
Assert.False(nodeTwo.Api.IsUp);
Assert.Equal("down", nodeTwo.Api.State);
Assert.Equal(503, nodeTwo.Api.StatusCode);
Assert.True(nodeTwo.Streaming.Configured);
Assert.False(nodeTwo.Streaming.IsUp);
Assert.Equal("down", nodeTwo.Streaming.State);
Assert.Equal(504, nodeTwo.Streaming.StatusCode);
}
private static HttpContext CreateHttpContextWithSession(bool isAdmin)
{
var context = new DefaultHttpContext();
context.Connection.LocalPort = 5275;
context.Items[AdminAuthSessionService.HttpContextSessionItemKey] = new AdminAuthSession
{
SessionId = "session-id",
UserId = "user-id",
UserName = "user",
IsAdministrator = isAdmin,
JellyfinAccessToken = "token",
JellyfinServerId = "server-id",
ExpiresAtUtc = DateTime.UtcNow.AddHours(1),
LastSeenUtc = DateTime.UtcNow
};
return context;
}
private static DiagnosticsController CreateController(
HttpContext httpContext,
Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
{
var logger = new Mock<ILogger<DiagnosticsController>>();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
var webHostEnvironment = new Mock<IWebHostEnvironment>();
webHostEnvironment.SetupGet(e => e.EnvironmentName).Returns(Environments.Development);
webHostEnvironment.SetupGet(e => e.ContentRootPath).Returns(Directory.GetCurrentDirectory());
var helperLogger = new Mock<ILogger<AdminHelperService>>();
var helperService = new AdminHelperService(
helperLogger.Object,
Options.Create(new JellyfinSettings()),
webHostEnvironment.Object);
var spotifyCookieLogger = new Mock<ILogger<SpotifySessionCookieService>>();
var spotifySessionCookieService = new SpotifySessionCookieService(
Options.Create(new SpotifyApiSettings()),
helperService,
spotifyCookieLogger.Object);
var redisLogger = new Mock<ILogger<RedisCacheService>>();
var redisCache = new RedisCacheService(
Options.Create(new RedisSettings
{
Enabled = false,
ConnectionString = "localhost:6379"
}),
redisLogger.Object,
new Microsoft.Extensions.Caching.Memory.MemoryCache(
new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
var httpClientFactory = new Mock<IHttpClientFactory>();
httpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>()))
.Returns(() => new HttpClient(new StubHttpMessageHandler(responseFactory)));
var controller = new DiagnosticsController(
logger.Object,
configuration,
Options.Create(new SpotifyApiSettings()),
Options.Create(new SpotifyImportSettings()),
Options.Create(new JellyfinSettings()),
Options.Create(new DeezerSettings()),
Options.Create(new QobuzSettings()),
Options.Create(new SquidWTFSettings()),
spotifySessionCookieService,
new SquidWtfEndpointCatalog(
new List<string>
{
"https://node-one.example",
"https://node-two.example"
},
new List<string>
{
"https://node-one.example",
"https://node-two.example"
}),
redisCache,
httpClientFactory.Object)
{
ControllerContext = new ControllerContext
{
HttpContext = httpContext
}
};
return controller;
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responseFactory;
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
{
_responseFactory = responseFactory;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
return Task.FromResult(_responseFactory(request));
}
}
}
@@ -0,0 +1,193 @@
using Moq;
using Microsoft.Extensions.Logging;
using allstarr.Models.Domain;
using allstarr.Services;
using allstarr.Services.Jellyfin;
namespace allstarr.Tests;
public class ExternalArtistAppearancesServiceTests
{
private readonly Mock<IMusicMetadataService> _metadataService = new();
private readonly ExternalArtistAppearancesService _service;
public ExternalArtistAppearancesServiceTests()
{
_service = new ExternalArtistAppearancesService(
_metadataService.Object,
Mock.Of<ILogger<ExternalArtistAppearancesService>>());
}
[Fact]
public async Task GetAppearsOnAlbumsAsync_FiltersPrimaryAlbumsAndDeduplicatesTrackDerivedAlbums()
{
var artist = new Artist
{
Id = "ext-squidwtf-artist-artist-a",
Name = "Artist A"
};
_metadataService
.Setup(service => service.GetArtistAsync("squidwtf", "artist-a", It.IsAny<CancellationToken>()))
.ReturnsAsync(artist);
_metadataService
.Setup(service => service.GetArtistAlbumsAsync("squidwtf", "artist-a", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Album>
{
new()
{
Id = "ext-squidwtf-album-own",
Title = "Own Album",
Artist = "Artist A",
ArtistId = artist.Id,
Year = 2024,
IsLocal = false,
ExternalProvider = "squidwtf",
ExternalId = "own"
},
new()
{
Id = "ext-squidwtf-album-feature",
Title = "Feature Album",
Artist = "Artist B",
ArtistId = "ext-squidwtf-artist-artist-b",
Year = 2023,
IsLocal = false,
ExternalProvider = "squidwtf",
ExternalId = "feature"
}
});
_metadataService
.Setup(service => service.GetArtistTracksAsync("squidwtf", "artist-a", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Song>
{
new()
{
AlbumId = "ext-squidwtf-album-own",
Album = "Own Album",
AlbumArtist = "Artist A",
Title = "Own Song",
Year = 2024
},
new()
{
AlbumId = "ext-squidwtf-album-feature",
Album = "Feature Album",
AlbumArtist = "Artist B",
Title = "Feature Song 1",
Year = 2023
},
new()
{
AlbumId = "ext-squidwtf-album-feature",
Album = "Feature Album",
AlbumArtist = "Artist B",
Title = "Feature Song 2",
Year = 2023
},
new()
{
AlbumId = "ext-squidwtf-album-comp",
Album = "Compilation",
AlbumArtist = "Various Artists",
Title = "Compilation Song",
Year = 2022,
CoverArtUrl = "https://example.com/cover.jpg",
TotalTracks = 10
}
});
var result = await _service.GetAppearsOnAlbumsAsync("squidwtf", "artist-a");
Assert.Collection(
result,
album =>
{
Assert.Equal("Feature Album", album.Title);
Assert.Equal("Artist B", album.Artist);
Assert.Equal("feature", album.ExternalId);
},
album =>
{
Assert.Equal("Compilation", album.Title);
Assert.Equal("Various Artists", album.Artist);
Assert.Equal("comp", album.ExternalId);
Assert.Equal(10, album.SongCount);
});
}
[Fact]
public async Task GetAppearsOnAlbumsAsync_WhenTrackDataIsUnavailable_FallsBackToNonPrimaryAlbumsFromAlbumList()
{
var artist = new Artist
{
Id = "ext-qobuz-artist-artist-a",
Name = "Artist A"
};
_metadataService
.Setup(service => service.GetArtistAsync("qobuz", "artist-a", It.IsAny<CancellationToken>()))
.ReturnsAsync(artist);
_metadataService
.Setup(service => service.GetArtistAlbumsAsync("qobuz", "artist-a", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Album>
{
new()
{
Id = "ext-qobuz-album-own",
Title = "Own Album",
Artist = "Artist A",
ArtistId = artist.Id,
Year = 2024,
IsLocal = false,
ExternalProvider = "qobuz",
ExternalId = "own"
},
new()
{
Id = "ext-qobuz-album-feature",
Title = "Feature Album",
Artist = "Artist C",
ArtistId = "ext-qobuz-artist-artist-c",
Year = 2021,
IsLocal = false,
ExternalProvider = "qobuz",
ExternalId = "feature"
}
});
_metadataService
.Setup(service => service.GetArtistTracksAsync("qobuz", "artist-a", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Song>());
var result = await _service.GetAppearsOnAlbumsAsync("qobuz", "artist-a");
var album = Assert.Single(result);
Assert.Equal("Feature Album", album.Title);
Assert.Equal("Artist C", album.Artist);
Assert.Equal("feature", album.ExternalId);
}
[Fact]
public async Task GetAppearsOnAlbumsAsync_WhenArtistLookupFails_ReturnsEmpty()
{
_metadataService
.Setup(service => service.GetArtistAsync("squidwtf", "missing", It.IsAny<CancellationToken>()))
.ReturnsAsync((Artist?)null);
_metadataService
.Setup(service => service.GetArtistAlbumsAsync("squidwtf", "missing", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Album>());
_metadataService
.Setup(service => service.GetArtistTracksAsync("squidwtf", "missing", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Song>());
var result = await _service.GetAppearsOnAlbumsAsync("squidwtf", "missing");
Assert.Empty(result);
}
}
@@ -0,0 +1,64 @@
using Microsoft.AspNetCore.Http;
using allstarr.Services.Common;
namespace allstarr.Tests;
public class ImageConditionalRequestHelperTests
{
[Fact]
public void ComputeStrongETag_SamePayload_ReturnsStableQuotedHash()
{
var payload = new byte[] { 1, 2, 3, 4 };
var first = ImageConditionalRequestHelper.ComputeStrongETag(payload);
var second = ImageConditionalRequestHelper.ComputeStrongETag(payload);
Assert.Equal(first, second);
Assert.StartsWith("\"", first);
Assert.EndsWith("\"", first);
}
[Fact]
public void MatchesIfNoneMatch_WithExactMatch_ReturnsTrue()
{
var headers = new HeaderDictionary
{
["If-None-Match"] = "\"ABC123\""
};
Assert.True(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"ABC123\""));
}
[Fact]
public void MatchesIfNoneMatch_WithMultipleValues_ReturnsTrueForMatchingEntry()
{
var headers = new HeaderDictionary
{
["If-None-Match"] = "\"stale\", \"fresh\""
};
Assert.True(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"fresh\""));
}
[Fact]
public void MatchesIfNoneMatch_WithWildcard_ReturnsTrue()
{
var headers = new HeaderDictionary
{
["If-None-Match"] = "*"
};
Assert.True(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"anything\""));
}
[Fact]
public void MatchesIfNoneMatch_WithoutMatch_ReturnsFalse()
{
var headers = new HeaderDictionary
{
["If-None-Match"] = "\"ABC123\""
};
Assert.False(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"XYZ789\""));
}
}
@@ -0,0 +1,43 @@
using System.Reflection;
using allstarr.Controllers;
namespace allstarr.Tests;
public class JellyfinControllerSearchLimitTests
{
[Theory]
[InlineData(null, 20, true, 20, 20, 20)]
[InlineData("MusicAlbum", 20, true, 0, 20, 0)]
[InlineData("Audio", 20, true, 20, 0, 0)]
[InlineData("MusicArtist", 20, true, 0, 0, 20)]
[InlineData("Playlist", 20, true, 0, 20, 0)]
[InlineData("Playlist", 20, false, 0, 0, 0)]
[InlineData("Audio,MusicArtist", 15, true, 15, 0, 15)]
[InlineData("BoxSet", 10, true, 0, 0, 0)]
public void GetExternalSearchLimits_UsesRequestedItemTypes(
string? includeItemTypes,
int limit,
bool includePlaylistsAsAlbums,
int expectedSongLimit,
int expectedAlbumLimit,
int expectedArtistLimit)
{
var requestedTypes = string.IsNullOrWhiteSpace(includeItemTypes)
? null
: includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var method = typeof(JellyfinController).GetMethod(
"GetExternalSearchLimits",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.NotNull(method);
var result = ((int SongLimit, int AlbumLimit, int ArtistLimit))method!.Invoke(
null,
new object?[] { requestedTypes, limit, includePlaylistsAsAlbums })!;
Assert.Equal(expectedSongLimit, result.SongLimit);
Assert.Equal(expectedAlbumLimit, result.AlbumLimit);
Assert.Equal(expectedArtistLimit, result.ArtistLimit);
}
}
@@ -0,0 +1,65 @@
using System.Reflection;
using System.Text.Json;
using allstarr.Controllers;
namespace allstarr.Tests;
public class JellyfinImageTagExtractionTests
{
[Fact]
public void ExtractImageTag_WithMatchingImageTagsObject_ReturnsRequestedTag()
{
using var document = JsonDocument.Parse("""
{
"ImageTags": {
"Primary": "playlist-primary-tag",
"Backdrop": "playlist-backdrop-tag"
}
}
""");
var imageTag = InvokeExtractImageTag(document.RootElement, "Primary");
Assert.Equal("playlist-primary-tag", imageTag);
}
[Fact]
public void ExtractImageTag_WithPrimaryImageTagFallback_ReturnsFallbackTag()
{
using var document = JsonDocument.Parse("""
{
"PrimaryImageTag": "primary-fallback-tag"
}
""");
var imageTag = InvokeExtractImageTag(document.RootElement, "Primary");
Assert.Equal("primary-fallback-tag", imageTag);
}
[Fact]
public void ExtractImageTag_WithoutMatchingTag_ReturnsNull()
{
using var document = JsonDocument.Parse("""
{
"ImageTags": {
"Backdrop": "playlist-backdrop-tag"
}
}
""");
var imageTag = InvokeExtractImageTag(document.RootElement, "Primary");
Assert.Null(imageTag);
}
private static string? InvokeExtractImageTag(JsonElement item, string imageType)
{
var method = typeof(JellyfinController).GetMethod(
"ExtractImageTag",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.NotNull(method);
return (string?)method!.Invoke(null, new object?[] { item, imageType });
}
}
@@ -0,0 +1,41 @@
using System.Reflection;
using allstarr.Controllers;
namespace allstarr.Tests;
public class JellyfinPlaylistRouteMatchingTests
{
[Theory]
[InlineData("playlists/abc123/items", "abc123")]
[InlineData("Playlists/abc123/Items", "abc123")]
[InlineData("/playlists/abc123/items/", "abc123")]
public void GetExactPlaylistItemsRequestId_ExactPlaylistItemsRoute_ReturnsPlaylistId(string path, string expectedPlaylistId)
{
var playlistId = InvokePrivateStatic<string?>("GetExactPlaylistItemsRequestId", path);
Assert.Equal(expectedPlaylistId, playlistId);
}
[Theory]
[InlineData("playlists/abc123/items/extra")]
[InlineData("users/user-1/playlists/abc123/items")]
[InlineData("items/abc123")]
[InlineData("playlists")]
public void GetExactPlaylistItemsRequestId_NonExactRoute_ReturnsNull(string path)
{
var playlistId = InvokePrivateStatic<string?>("GetExactPlaylistItemsRequestId", path);
Assert.Null(playlistId);
}
private static T InvokePrivateStatic<T>(string methodName, params object?[] args)
{
var method = typeof(JellyfinController).GetMethod(
methodName,
BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(method);
var result = method!.Invoke(null, args);
return (T)result!;
}
}
+237 -5
View File
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.DependencyInjection;
@@ -20,6 +21,7 @@ public class JellyfinProxyServiceTests
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
private readonly RedisCacheService _cache;
private readonly JellyfinSettings _settings;
private readonly IHttpContextAccessor _httpContextAccessor;
public JellyfinProxyServiceTests()
{
@@ -31,7 +33,7 @@ public class JellyfinProxyServiceTests
var redisSettings = new RedisSettings { Enabled = false };
var mockCacheLogger = new Mock<ILogger<RedisCacheService>>();
_cache = new RedisCacheService(Options.Create(redisSettings), mockCacheLogger.Object);
_cache = new RedisCacheService(Options.Create(redisSettings), mockCacheLogger.Object, new MemoryCache(new MemoryCacheOptions()));
_settings = new JellyfinSettings
{
@@ -45,19 +47,21 @@ public class JellyfinProxyServiceTests
};
var httpContext = new DefaultHttpContext();
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
_httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
var userResolver = CreateUserContextResolver(_httpContextAccessor);
// Initialize cache settings for tests
var serviceCollection = new Microsoft.Extensions.DependencyInjection.ServiceCollection();
serviceCollection.Configure<CacheSettings>(options => { }); // Use defaults
var serviceProvider = serviceCollection.BuildServiceProvider();
CacheExtensions.InitializeCacheSettings(serviceProvider);
allstarr.Services.Common.CacheExtensions.InitializeCacheSettings(serviceProvider);
_service = new JellyfinProxyService(
_mockHttpClientFactory.Object,
Options.Create(_settings),
httpContextAccessor,
_httpContextAccessor,
userResolver,
mockLogger.Object,
_cache);
}
@@ -93,6 +97,21 @@ public class JellyfinProxyServiceTests
Assert.Equal(500, statusCode);
}
[Fact]
public async Task GetJsonAsync_ServerErrorWithJsonBody_ReturnsParsedErrorDocument()
{
// Arrange
SetupMockResponse(HttpStatusCode.Unauthorized, "{\"Message\":\"Token expired\"}", "application/json");
// Act
var (body, statusCode) = await _service.GetJsonAsync("Items");
// Assert
Assert.NotNull(body);
Assert.Equal(401, statusCode);
Assert.Equal("Token expired", body.RootElement.GetProperty("Message").GetString());
}
[Fact]
public async Task GetJsonAsync_WithoutClientHeaders_SendsNoAuth()
{
@@ -228,6 +247,44 @@ public class JellyfinProxyServiceTests
Assert.Equal("test query", searchTermValue);
}
[Fact]
public async Task SearchAsync_WithClientToken_ResolvesAndAppendsRequestUserId()
{
var requestedUris = new List<string>();
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync((HttpRequestMessage req, CancellationToken _) =>
{
requestedUris.Add(req.RequestUri!.ToString());
if (req.RequestUri!.AbsolutePath.EndsWith("/Users/Me", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"Id\":\"resolved-user\"}")
};
}
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"Items\":[],\"TotalRecordCount\":0}")
};
});
var headers = new HeaderDictionary
{
["X-Emby-Token"] = "token-123"
};
await _service.SearchAsync("test query", new[] { "Audio" }, 25, clientHeaders: headers);
Assert.Contains(requestedUris, uri => uri.EndsWith("/Users/Me"));
Assert.Contains(requestedUris, uri => uri.Contains("userId=resolved-user", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task GetItemAsync_RequestsCorrectEndpoint()
{
@@ -311,6 +368,169 @@ public class JellyfinProxyServiceTests
Assert.Contains("UserId=user-abc", query);
}
[Fact]
public async Task GetPassthroughResponseAsync_WithRepeatedFields_PreservesAllFieldParameters()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"Items\":[]}")
});
// Act
var response = await _service.GetPassthroughResponseAsync(
"Playlists/playlist-123/Items?Fields=Genres&Fields=DateCreated&Fields=MediaSources&UserId=user-abc");
// Assert
Assert.NotNull(captured);
var query = captured!.RequestUri!.Query;
Assert.Contains("Fields=Genres", query);
Assert.Contains("Fields=DateCreated", query);
Assert.Contains("Fields=MediaSources", query);
Assert.Contains("UserId=user-abc", query);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task GetPassthroughResponseAsync_WithClientAuth_ForwardsAuthHeader()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"Items\":[]}")
});
var headers = new HeaderDictionary
{
["X-Emby-Authorization"] = "MediaBrowser Token=\"abc\""
};
// Act
var response = await _service.GetPassthroughResponseAsync(
"Playlists/playlist-123/Items?Fields=Genres",
headers);
// Assert
Assert.NotNull(captured);
Assert.True(captured!.Headers.TryGetValues("X-Emby-Authorization", out var values));
Assert.Contains("MediaBrowser Token=\"abc\"", values);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task SendAsync_WithNoBody_PreservesEmptyRequestBody()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.NoContent));
var headers = new HeaderDictionary
{
["X-Emby-Authorization"] = "MediaBrowser Token=\"abc\""
};
// Act
var (_, statusCode) = await _service.SendAsync(
HttpMethod.Post,
"Sessions/session-123/Playing/Pause?controllingUserId=user-123",
null,
headers);
// Assert
Assert.Equal(204, statusCode);
Assert.NotNull(captured);
Assert.Equal(HttpMethod.Post, captured!.Method);
Assert.Null(captured.Content);
}
[Fact]
public async Task SendAsync_WithCustomContentType_PreservesOriginalType()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.NoContent));
var headers = new HeaderDictionary
{
["X-Emby-Authorization"] = "MediaBrowser Token=\"abc\""
};
// Act
await _service.SendAsync(
HttpMethod.Put,
"Sessions/session-123/Command/DisplayMessage",
"{\"Text\":\"hello\"}",
headers,
"application/json; charset=utf-8");
// Assert
Assert.NotNull(captured);
Assert.Equal(HttpMethod.Put, captured!.Method);
Assert.NotNull(captured.Content);
Assert.Equal("application/json; charset=utf-8", captured.Content!.Headers.ContentType!.ToString());
}
[Fact]
public async Task GetPassthroughResponseAsync_WithAcceptEncoding_ForwardsCompressionHeaders()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"Items\":[]}")
});
var headers = new HeaderDictionary
{
["Accept-Encoding"] = "gzip, br",
["User-Agent"] = "Finamp/1.0",
["Accept-Language"] = "en-US"
};
// Act
var response = await _service.GetPassthroughResponseAsync(
"Playlists/playlist-123/Items?Fields=Genres",
headers);
// Assert
Assert.NotNull(captured);
Assert.True(captured!.Headers.TryGetValues("Accept-Encoding", out var encodings));
Assert.Contains("gzip", encodings);
Assert.Contains("br", encodings);
Assert.True(captured.Headers.TryGetValues("User-Agent", out var userAgents));
Assert.Contains("Finamp/1.0", userAgents);
Assert.True(captured.Headers.TryGetValues("Accept-Language", out var languages));
Assert.Contains("en-US", languages);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task GetJsonAsync_WithEndpointAndExplicitQuery_MergesWithExplicitPrecedence()
{
@@ -466,12 +686,14 @@ public class JellyfinProxyServiceTests
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
var redisSettings = new RedisSettings { Enabled = false };
var mockCacheLogger = new Mock<ILogger<RedisCacheService>>();
var cache = new RedisCacheService(Options.Create(redisSettings), mockCacheLogger.Object);
var cache = new RedisCacheService(Options.Create(redisSettings), mockCacheLogger.Object, new MemoryCache(new MemoryCacheOptions()));
var userResolver = CreateUserContextResolver(httpContextAccessor);
var service = new JellyfinProxyService(
_mockHttpClientFactory.Object,
Options.Create(_settings),
httpContextAccessor,
userResolver,
mockLogger.Object,
cache);
@@ -497,4 +719,14 @@ public class JellyfinProxyServiceTests
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response);
}
private JellyfinUserContextResolver CreateUserContextResolver(IHttpContextAccessor httpContextAccessor)
{
return new JellyfinUserContextResolver(
httpContextAccessor,
_mockHttpClientFactory.Object,
Options.Create(_settings),
new MemoryCache(new MemoryCacheOptions()),
new Mock<ILogger<JellyfinUserContextResolver>>().Object);
}
}
@@ -47,6 +47,8 @@ public class JellyfinResponseBuilderTests
Assert.Equal(1, result["ParentIndexNumber"]);
Assert.Equal(2023, result["ProductionYear"]);
Assert.Equal(245 * TimeSpan.TicksPerSecond, result["RunTimeTicks"]);
Assert.NotNull(result["AudioInfo"]);
Assert.Equal(false, result["CanDelete"]);
}
[Fact]
@@ -192,6 +194,9 @@ public class JellyfinResponseBuilderTests
Assert.Equal("Famous Band", result["AlbumArtist"]);
Assert.Equal(2020, result["ProductionYear"]);
Assert.Equal(12, result["ChildCount"]);
Assert.Equal("Greatest Hits", result["SortName"]);
Assert.NotNull(result["DateCreated"]);
Assert.NotNull(result["BasicSyncInfo"]);
}
[Fact]
@@ -215,6 +220,9 @@ public class JellyfinResponseBuilderTests
Assert.Equal("MusicArtist", result["Type"]);
Assert.Equal(true, result["IsFolder"]);
Assert.Equal(5, result["AlbumCount"]);
Assert.Equal("The Rockers", result["SortName"]);
Assert.Equal(1.0, result["PrimaryImageAspectRatio"]);
Assert.NotNull(result["BasicSyncInfo"]);
}
[Fact]
@@ -243,6 +251,9 @@ public class JellyfinResponseBuilderTests
Assert.Equal("DJ Cool", result["AlbumArtist"]);
Assert.Equal(50, result["ChildCount"]);
Assert.Equal(2023, result["ProductionYear"]);
Assert.Equal("Summer Vibes [S/P]", result["SortName"]);
Assert.NotNull(result["DateCreated"]);
Assert.NotNull(result["BasicSyncInfo"]);
}
[Fact]
@@ -0,0 +1,224 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using allstarr.Controllers;
namespace allstarr.Tests;
public class JellyfinSearchInterleaveTests
{
[Fact]
public void InterleaveByScore_PrimaryOnly_PreservesOriginalOrder()
{
var controller = CreateController();
var primary = new List<Dictionary<string, object?>>
{
CreateItem("zzz filler"),
CreateItem("BTS Anthem")
};
var result = InvokeInterleaveByScore(controller, primary, [], "bts", 5.0);
Assert.Equal(["zzz filler", "BTS Anthem"], result.Select(GetName));
}
[Fact]
public void InterleaveByScore_SecondaryOnly_PreservesOriginalOrder()
{
var controller = CreateController();
var secondary = new List<Dictionary<string, object?>>
{
CreateItem("zzz filler"),
CreateItem("BTS Anthem")
};
var result = InvokeInterleaveByScore(controller, [], secondary, "bts", 5.0);
Assert.Equal(["zzz filler", "BTS Anthem"], result.Select(GetName));
}
[Fact]
public void InterleaveByScore_StrongerHeadMatch_LeadsWithoutReorderingSource()
{
var controller = CreateController();
var primary = new List<Dictionary<string, object?>>
{
CreateItem("luther remastered"),
CreateItem("zzz filler")
};
var secondary = new List<Dictionary<string, object?>>
{
CreateItem("luther"),
CreateItem("yyy filler")
};
var result = InvokeInterleaveByScore(controller, primary, secondary, "luther", 0.0);
Assert.Equal(["luther", "luther remastered", "zzz filler", "yyy filler"], result.Select(GetName));
}
[Fact]
public void InterleaveByScore_TiedScores_PreferPrimaryQueueHead()
{
var controller = CreateController();
var primary = new List<Dictionary<string, object?>>
{
CreateItem("bts", "p1"),
CreateItem("bts", "p2")
};
var secondary = new List<Dictionary<string, object?>>
{
CreateItem("bts", "s1"),
CreateItem("bts", "s2")
};
var result = InvokeInterleaveByScore(controller, primary, secondary, "bts", 0.0);
Assert.Equal(["p1", "p2", "s1", "s2"], result.Select(GetId));
}
[Fact]
public void InterleaveByScore_StrongerLaterPrimaryHead_DoesNotBypassCurrentQueueHead()
{
var controller = CreateController();
var primary = new List<Dictionary<string, object?>>
{
CreateItem("zzz filler", "p1"),
CreateItem("bts local later", "p2")
};
var secondary = new List<Dictionary<string, object?>>
{
CreateItem("bts", "s1"),
CreateItem("bts live", "s2")
};
var result = InvokeInterleaveByScore(controller, primary, secondary, "bts", 0.0);
Assert.Equal(["s1", "s2", "p1", "p2"], result.Select(GetId));
}
[Fact]
public void InterleaveByScore_JellyfinBoost_CanWinCloseHeadToHead()
{
var controller = CreateController();
var primary = new List<Dictionary<string, object?>>
{
CreateItem("luther remastered", "p1")
};
var secondary = new List<Dictionary<string, object?>>
{
CreateItem("luther", "s1")
};
var result = InvokeInterleaveByScore(controller, primary, secondary, "luther", 5.0);
Assert.Equal(["p1", "s1"], result.Select(GetId));
}
[Fact]
public void CalculateItemRelevanceScore_SongUsesArtistContext()
{
var controller = CreateController();
var withArtist = CreateTypedItem("Audio", "cardigan", "song-with-artist");
withArtist["Artists"] = new[] { "Taylor Swift" };
var withoutArtist = CreateTypedItem("Audio", "cardigan", "song-without-artist");
var withArtistScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", withArtist);
var withoutArtistScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", withoutArtist);
Assert.True(withArtistScore > withoutArtistScore);
}
[Fact]
public void CalculateItemRelevanceScore_AlbumUsesArtistContext()
{
var controller = CreateController();
var withArtist = CreateTypedItem("MusicAlbum", "folklore", "album-with-artist");
withArtist["AlbumArtist"] = "Taylor Swift";
var withoutArtist = CreateTypedItem("MusicAlbum", "folklore", "album-without-artist");
var withArtistScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", withArtist);
var withoutArtistScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", withoutArtist);
Assert.True(withArtistScore > withoutArtistScore);
}
[Fact]
public void CalculateItemRelevanceScore_ArtistIgnoresNonNameMetadata()
{
var controller = CreateController();
var plainArtist = CreateTypedItem("MusicArtist", "Taylor Swift", "artist-plain");
var noisyArtist = CreateTypedItem("MusicArtist", "Taylor Swift", "artist-noisy");
noisyArtist["AlbumArtist"] = "Completely Different";
noisyArtist["Artists"] = new[] { "Someone Else" };
var plainScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", plainArtist);
var noisyScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", noisyArtist);
Assert.Equal(plainScore, noisyScore);
}
private static JellyfinController CreateController()
{
return (JellyfinController)RuntimeHelpers.GetUninitializedObject(typeof(JellyfinController));
}
private static List<Dictionary<string, object?>> InvokeInterleaveByScore(
JellyfinController controller,
List<Dictionary<string, object?>> primary,
List<Dictionary<string, object?>> secondary,
string query,
double primaryBoost)
{
var method = typeof(JellyfinController).GetMethod(
"InterleaveByScore",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
return (List<Dictionary<string, object?>>)method!.Invoke(
controller,
[primary, secondary, query, primaryBoost])!;
}
private static double InvokeCalculateItemRelevanceScore(
JellyfinController controller,
string query,
Dictionary<string, object?> item)
{
var method = typeof(JellyfinController).GetMethod(
"CalculateItemRelevanceScore",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
return (double)method!.Invoke(controller, [query, item])!;
}
private static Dictionary<string, object?> CreateItem(string name, string? id = null)
{
return new Dictionary<string, object?>
{
["Name"] = name,
["Id"] = id ?? name
};
}
private static Dictionary<string, object?> CreateTypedItem(string type, string name, string id)
{
var item = CreateItem(name, id);
item["Type"] = type;
return item;
}
private static string GetName(Dictionary<string, object?> item)
{
return item["Name"]?.ToString() ?? string.Empty;
}
private static string GetId(Dictionary<string, object?> item)
{
return item["Id"]?.ToString() ?? string.Empty;
}
}
@@ -0,0 +1,93 @@
using System.Reflection;
using System.Text.Json;
using allstarr.Controllers;
using allstarr.Models.Jellyfin;
namespace allstarr.Tests;
public class JellyfinSearchResponseSerializationTests
{
[Fact]
public void SerializeSearchResponseJson_PreservesPascalCaseShape()
{
var payload = new JellyfinItemsResponse
{
Items =
[
new Dictionary<string, object?>
{
["Name"] = "BTS",
["Type"] = "MusicAlbum"
}
],
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.Equal(
"{\"Items\":[{\"Name\":\"BTS\",\"Type\":\"MusicAlbum\"}],\"TotalRecordCount\":1,\"StartIndex\":0}",
json);
}
[Fact]
public void SerializeSearchResponseJson_FallsBackForMixedRuntimeShapes()
{
using var rawDoc = JsonDocument.Parse("""
{
"ServerId": "c17d351d3af24c678a6d8049c212d522",
"RunTimeTicks": 2234068710
}
""");
var payload = new JellyfinItemsResponse
{
Items =
[
new Dictionary<string, object?>
{
["Name"] = "Harleys in Hawaii",
["Type"] = "MusicAlbum",
["MediaSources"] = new Dictionary<string, object?>[]
{
new Dictionary<string, object?>
{
["RunTimeTicks"] = 2234068710L,
["MediaAttachments"] = new List<object>(),
["Formats"] = new List<string>(),
["RequiredHttpHeaders"] = new Dictionary<string, string>()
}
},
["ArtistItems"] = new List<object>
{
new Dictionary<string, object?> { ["Name"] = "Katy Perry" }
},
["RawItem"] = rawDoc.RootElement.Clone()
}
],
TotalRecordCount = 1,
StartIndex = 0
};
var method = typeof(JellyfinController).GetMethod(
"SerializeSearchResponseJson",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(method);
var json = (string)method!.Invoke(null, new object?[] { payload })!;
Assert.Contains("\"Items\":[{", json);
Assert.Contains("\"MediaAttachments\":[]", json);
Assert.Contains("\"ArtistItems\":[{\"Name\":\"Katy Perry\"}]", json);
Assert.Contains("\"RawItem\":{\"ServerId\":\"c17d351d3af24c678a6d8049c212d522\",\"RunTimeTicks\":2234068710}", json);
Assert.Contains("\"TotalRecordCount\":1", json);
}
}
+63 -2
View File
@@ -52,9 +52,17 @@ public class JellyfinSessionManagerTests
public async Task RemoveSessionAsync_ReportsPlaybackStopButDoesNotLogoutUserSession()
{
var requestedPaths = new ConcurrentBag<string>();
var requestBodies = new ConcurrentDictionary<string, string>();
var handler = new DelegateHttpMessageHandler((request, _) =>
{
requestedPaths.Add(request.RequestUri?.AbsolutePath ?? string.Empty);
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
requestedPaths.Add(path);
if (request.Content != null)
{
requestBodies[path] = request.Content.ReadAsStringAsync().GetAwaiter().GetResult();
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent));
});
@@ -89,6 +97,12 @@ public class JellyfinSessionManagerTests
Assert.Contains("/Sessions/Capabilities/Full", requestedPaths);
Assert.Contains("/Sessions/Playing/Stopped", requestedPaths);
Assert.DoesNotContain("/Sessions/Logout", requestedPaths);
Assert.Equal(
"{\"PlayableMediaTypes\":[\"Audio\"],\"SupportedCommands\":[\"Play\",\"Playstate\",\"PlayNext\"],\"SupportsMediaControl\":true,\"SupportsPersistentIdentifier\":true,\"SupportsSync\":false}",
requestBodies["/Sessions/Capabilities/Full"]);
Assert.Equal(
"{\"ItemId\":\"item-123\",\"PositionTicks\":42}",
requestBodies["/Sessions/Playing/Stopped"]);
}
[Fact]
@@ -132,6 +146,46 @@ public class JellyfinSessionManagerTests
Assert.Equal(45 * TimeSpan.TicksPerSecond, state.PositionTicks);
}
[Fact]
public async Task EnsureSessionAsync_WithProxiedWebSocket_DoesNotPostSyntheticCapabilities()
{
var requestedPaths = new ConcurrentBag<string>();
var handler = new DelegateHttpMessageHandler((request, _) =>
{
requestedPaths.Add(request.RequestUri?.AbsolutePath ?? string.Empty);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent));
});
var settings = new JellyfinSettings
{
Url = "http://127.0.0.1:1",
ApiKey = "server-api-key",
ClientName = "Allstarr",
DeviceName = "Allstarr",
DeviceId = "allstarr",
ClientVersion = "1.0"
};
var proxyService = CreateProxyService(handler, settings);
using var manager = new JellyfinSessionManager(
proxyService,
Options.Create(settings),
NullLogger<JellyfinSessionManager>.Instance);
var headers = new HeaderDictionary
{
["X-Emby-Authorization"] =
"MediaBrowser Client=\"Finamp\", Device=\"Android Auto\", DeviceId=\"dev-123\", Version=\"1.0\", Token=\"abc\""
};
await manager.RegisterProxiedWebSocketAsync("dev-123");
var ensured = await manager.EnsureSessionAsync("dev-123", "Finamp", "Android Auto", "1.0", headers);
Assert.True(ensured);
Assert.DoesNotContain("/Sessions/Capabilities/Full", requestedPaths);
}
private static JellyfinProxyService CreateProxyService(HttpMessageHandler handler, JellyfinSettings settings)
{
var httpClientFactory = new TestHttpClientFactory(handler);
@@ -142,12 +196,19 @@ public class JellyfinSessionManagerTests
var cache = new RedisCacheService(
Options.Create(new RedisSettings { Enabled = false }),
NullLogger<RedisCacheService>.Instance);
NullLogger<RedisCacheService>.Instance,
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
return new JellyfinProxyService(
httpClientFactory,
Options.Create(settings),
httpContextAccessor,
new JellyfinUserContextResolver(
httpContextAccessor,
httpClientFactory,
Options.Create(settings),
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()),
NullLogger<JellyfinUserContextResolver>.Instance),
NullLogger<JellyfinProxyService>.Instance,
cache);
}
+2 -1
View File
@@ -1,5 +1,6 @@
using Xunit;
using Moq;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using allstarr.Services.Lyrics;
using allstarr.Services.Common;
@@ -23,7 +24,7 @@ public class LrclibServiceTests
// Create mock Redis cache
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false });
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object);
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object, new MemoryCache(new MemoryCacheOptions()));
_httpClient = new HttpClient
{
+200 -16
View File
@@ -1,8 +1,12 @@
using Xunit;
using Moq;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Text.Json;
using allstarr.Models.Domain;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
using allstarr.Models.Settings;
@@ -23,11 +27,19 @@ public class RedisCacheServiceTests
});
}
private RedisCacheService CreateService(IMemoryCache? memoryCache = null, IOptions<RedisSettings>? settings = null)
{
return new RedisCacheService(
settings ?? _settings,
_mockLogger.Object,
memoryCache ?? new MemoryCache(new MemoryCacheOptions()));
}
[Fact]
public void Constructor_InitializesWithSettings()
{
// Act
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
// Assert
Assert.NotNull(service);
@@ -45,7 +57,7 @@ public class RedisCacheServiceTests
});
// Act - Constructor will try to connect but should handle failure gracefully
var service = new RedisCacheService(enabledSettings, _mockLogger.Object);
var service = CreateService(settings: enabledSettings);
// Assert - Service should be created even if connection fails
Assert.NotNull(service);
@@ -55,7 +67,7 @@ public class RedisCacheServiceTests
public async Task GetStringAsync_WhenDisabled_ReturnsNull()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
// Act
var result = await service.GetStringAsync("test:key");
@@ -68,7 +80,7 @@ public class RedisCacheServiceTests
public async Task GetAsync_WhenDisabled_ReturnsNull()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
// Act
var result = await service.GetAsync<TestObject>("test:key");
@@ -81,7 +93,7 @@ public class RedisCacheServiceTests
public async Task SetStringAsync_WhenDisabled_ReturnsFalse()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
// Act
var result = await service.SetStringAsync("test:key", "test value");
@@ -94,7 +106,7 @@ public class RedisCacheServiceTests
public async Task SetAsync_WhenDisabled_ReturnsFalse()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
var testObj = new TestObject { Id = 1, Name = "Test" };
// Act
@@ -108,7 +120,7 @@ public class RedisCacheServiceTests
public async Task DeleteAsync_WhenDisabled_ReturnsFalse()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
// Act
var result = await service.DeleteAsync("test:key");
@@ -121,7 +133,7 @@ public class RedisCacheServiceTests
public async Task ExistsAsync_WhenDisabled_ReturnsFalse()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
// Act
var result = await service.ExistsAsync("test:key");
@@ -134,7 +146,7 @@ public class RedisCacheServiceTests
public async Task DeleteByPatternAsync_WhenDisabled_ReturnsZero()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
// Act
var result = await service.DeleteByPatternAsync("test:*");
@@ -147,7 +159,7 @@ public class RedisCacheServiceTests
public async Task SetStringAsync_WithExpiry_AcceptsTimeSpan()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
var expiry = TimeSpan.FromHours(1);
// Act
@@ -161,7 +173,7 @@ public class RedisCacheServiceTests
public async Task SetAsync_WithExpiry_AcceptsTimeSpan()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
var testObj = new TestObject { Id = 1, Name = "Test" };
var expiry = TimeSpan.FromDays(30);
@@ -176,14 +188,14 @@ public class RedisCacheServiceTests
public void IsEnabled_ReflectsSettings()
{
// Arrange
var disabledService = new RedisCacheService(_settings, _mockLogger.Object);
var disabledService = CreateService();
var enabledSettings = Options.Create(new RedisSettings
{
Enabled = true,
ConnectionString = "localhost:6379"
});
var enabledService = new RedisCacheService(enabledSettings, _mockLogger.Object);
var enabledService = CreateService(settings: enabledSettings);
// Assert
Assert.False(disabledService.IsEnabled);
@@ -194,7 +206,7 @@ public class RedisCacheServiceTests
public async Task GetAsync_DeserializesComplexObjects()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
// Act
var result = await service.GetAsync<ComplexTestObject>("test:complex");
@@ -207,7 +219,7 @@ public class RedisCacheServiceTests
public async Task SetAsync_SerializesComplexObjects()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
var complexObj = new ComplexTestObject
{
Id = 1,
@@ -238,12 +250,184 @@ public class RedisCacheServiceTests
});
// Act
var service = new RedisCacheService(customSettings, _mockLogger.Object);
var service = CreateService(settings: customSettings);
// Assert
Assert.NotNull(service);
}
[Fact]
public async Task SetStringAsync_WhenDisabled_CachesValueInMemory()
{
var service = CreateService();
var setResult = await service.SetStringAsync("test:key", "test value");
var cachedValue = await service.GetStringAsync("test:key");
Assert.False(setResult);
Assert.Equal("test value", cachedValue);
}
[Fact]
public async Task SetAsync_WhenDisabled_CachesSerializedObjectInMemory()
{
var service = CreateService();
var expected = new TestObject { Id = 42, Name = "Tiered" };
var setResult = await service.SetAsync("test:object", expected);
var cachedValue = await service.GetAsync<TestObject>("test:object");
Assert.False(setResult);
Assert.NotNull(cachedValue);
Assert.Equal(expected.Id, cachedValue.Id);
Assert.Equal(expected.Name, cachedValue.Name);
}
[Fact]
public async Task ExistsAsync_WhenValueOnlyExistsInMemory_ReturnsTrue()
{
var service = CreateService();
await service.SetStringAsync("test:key", "test value");
var exists = await service.ExistsAsync("test:key");
Assert.True(exists);
}
[Fact]
public async Task DeleteAsync_WhenValueOnlyExistsInMemory_EvictsEntry()
{
var service = CreateService();
await service.SetStringAsync("test:key", "test value");
var deleted = await service.DeleteAsync("test:key");
var cachedValue = await service.GetStringAsync("test:key");
Assert.False(deleted);
Assert.Null(cachedValue);
}
[Fact]
public async Task DeleteByPatternAsync_WhenValuesOnlyExistInMemory_RemovesMatchingEntries()
{
var service = CreateService();
await service.SetStringAsync("search:one", "1");
await service.SetStringAsync("search:two", "2");
await service.SetStringAsync("other:one", "3");
var deletedCount = await service.DeleteByPatternAsync("search:*");
Assert.Equal(2, deletedCount);
Assert.Null(await service.GetStringAsync("search:one"));
Assert.Null(await service.GetStringAsync("search:two"));
Assert.Equal("3", await service.GetStringAsync("other:one"));
}
[Fact]
public async Task SetStringAsync_ImageKeysDoNotUseMemoryCache()
{
var service = CreateService();
await service.SetStringAsync("image:test:key", "binary-ish");
var cachedValue = await service.GetStringAsync("image:test:key");
var exists = await service.ExistsAsync("image:test:key");
Assert.Null(cachedValue);
Assert.False(exists);
}
[Fact]
public async Task SetAsync_WhenSongContainsRawJellyfinMetadata_CachesSerializedValueInMemory()
{
var service = CreateService();
var songs = new List<Song> { CreateLocalSongWithRawJellyfinMetadata() };
var setResult = await service.SetAsync("test:songs:raw-jellyfin", songs);
var cachedValue = await service.GetAsync<List<Song>>("test:songs:raw-jellyfin");
Assert.False(setResult);
Assert.NotNull(cachedValue);
var roundTrippedSong = Assert.Single(cachedValue!);
Assert.True(JellyfinItemSnapshotHelper.TryGetClonedRawItemSnapshot(roundTrippedSong, out var rawItem));
Assert.Equal("song-1", ((JsonElement)rawItem["Id"]!).GetString());
var mediaSources = Assert.IsType<JsonElement>(roundTrippedSong.JellyfinMetadata!["MediaSources"]);
Assert.Equal(JsonValueKind.Array, mediaSources.ValueKind);
Assert.Equal(2234068710L, mediaSources[0].GetProperty("RunTimeTicks").GetInt64());
}
[Fact]
public async Task SetAsync_WhenMatchedTracksContainRawJellyfinMetadata_CachesSerializedValueInMemory()
{
var service = CreateService();
var matchedTracks = new List<MatchedTrack>
{
new()
{
Position = 0,
SpotifyId = "spotify-1",
SpotifyTitle = "Track",
SpotifyArtist = "Artist",
MatchType = "fuzzy",
MatchedSong = CreateLocalSongWithRawJellyfinMetadata()
}
};
var setResult = await service.SetAsync("test:matched:raw-jellyfin", matchedTracks);
var cachedValue = await service.GetAsync<List<MatchedTrack>>("test:matched:raw-jellyfin");
Assert.False(setResult);
Assert.NotNull(cachedValue);
var roundTrippedMatch = Assert.Single(cachedValue!);
Assert.True(
JellyfinItemSnapshotHelper.TryGetClonedRawItemSnapshot(roundTrippedMatch.MatchedSong, out var rawItem));
Assert.Equal("song-1", ((JsonElement)rawItem["Id"]!).GetString());
var mediaSources =
Assert.IsType<JsonElement>(roundTrippedMatch.MatchedSong.JellyfinMetadata!["MediaSources"]);
Assert.Equal(JsonValueKind.Array, mediaSources.ValueKind);
Assert.Equal("song-1", mediaSources[0].GetProperty("Id").GetString());
}
private static Song CreateLocalSongWithRawJellyfinMetadata()
{
var song = new Song
{
Id = "song-1",
Title = "Track",
Artist = "Artist",
Album = "Album",
IsLocal = true
};
using var doc = JsonDocument.Parse("""
{
"Id": "song-1",
"ServerId": "c17d351d3af24c678a6d8049c212d522",
"RunTimeTicks": 2234068710,
"MediaSources": [
{
"Id": "song-1",
"RunTimeTicks": 2234068710
}
]
}
""");
JellyfinItemSnapshotHelper.StoreRawItemSnapshot(song, doc.RootElement);
song.JellyfinMetadata ??= new Dictionary<string, object?>();
song.JellyfinMetadata["MediaSources"] =
JsonSerializer.Deserialize<object>(doc.RootElement.GetProperty("MediaSources").GetRawText());
return song;
}
private class TestObject
{
public int Id { get; set; }
@@ -0,0 +1,88 @@
using allstarr.Services.Common;
using Microsoft.Extensions.Configuration;
namespace allstarr.Tests;
public sealed class RuntimeEnvConfigurationTests : IDisposable
{
private readonly string _envFilePath = Path.Combine(
Path.GetTempPath(),
$"allstarr-runtime-{Guid.NewGuid():N}.env");
[Fact]
public void MapEnvVarToConfiguration_MapsFlatKeyToNestedConfigKey()
{
var mappings = RuntimeEnvConfiguration
.MapEnvVarToConfiguration("SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS", "7")
.ToList();
var mapping = Assert.Single(mappings);
Assert.Equal("SpotifyImport:MatchingIntervalHours", mapping.Key);
Assert.Equal("7", mapping.Value);
}
[Fact]
public void MapEnvVarToConfiguration_MapsSharedBackendKeysToBothSections()
{
var mappings = RuntimeEnvConfiguration
.MapEnvVarToConfiguration("MUSIC_SERVICE", "Qobuz")
.OrderBy(x => x.Key, StringComparer.Ordinal)
.ToList();
Assert.Equal(2, mappings.Count);
Assert.Equal("Jellyfin:MusicService", mappings[0].Key);
Assert.Equal("Qobuz", mappings[0].Value);
Assert.Equal("Subsonic:MusicService", mappings[1].Key);
Assert.Equal("Qobuz", mappings[1].Value);
}
[Fact]
public void MapEnvVarToConfiguration_IgnoresComposeOnlyMountKeys()
{
var mappings = RuntimeEnvConfiguration
.MapEnvVarToConfiguration("DOWNLOAD_PATH", "./downloads")
.ToList();
Assert.Empty(mappings);
}
[Fact]
public void LoadDotEnvOverrides_StripsQuotesAndSupportsDoubleUnderscoreKeys()
{
File.WriteAllText(
_envFilePath,
"""
SPOTIFY_API_SESSION_COOKIE="secret-cookie"
Admin__EnableEnvExport=true
""");
var overrides = RuntimeEnvConfiguration.LoadDotEnvOverrides(_envFilePath);
Assert.Equal("secret-cookie", overrides["SpotifyApi:SessionCookie"]);
Assert.Equal("true", overrides["Admin:EnableEnvExport"]);
}
[Fact]
public void AddDotEnvOverrides_OverridesEarlierConfigurationValues()
{
File.WriteAllText(_envFilePath, "SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=7\n");
var configuration = new ConfigurationManager();
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["SpotifyImport:MatchingIntervalHours"] = "24"
});
RuntimeEnvConfiguration.AddDotEnvOverrides(configuration, _envFilePath);
Assert.Equal(7, configuration.GetValue<int>("SpotifyImport:MatchingIntervalHours"));
}
public void Dispose()
{
if (File.Exists(_envFilePath))
{
File.Delete(_envFilePath);
}
}
}
+2 -1
View File
@@ -2,6 +2,7 @@ using Xunit;
using Moq;
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using allstarr.Services.Spotify;
@@ -30,7 +31,7 @@ public class SpotifyMappingServiceTests
ConnectionString = "localhost:6379"
});
_cache = new RedisCacheService(redisSettings, _mockCacheLogger.Object);
_cache = new RedisCacheService(redisSettings, _mockCacheLogger.Object, new MemoryCache(new MemoryCacheOptions()));
_service = new SpotifyMappingService(_cache, _mockLogger.Object);
}
@@ -0,0 +1,90 @@
using allstarr.Models.Settings;
using allstarr.Services.Common;
namespace allstarr.Tests;
public class SpotifyPlaylistScopeResolverTests
{
[Fact]
public void ResolveConfig_PrefersExactJellyfinIdOverDuplicatePlaylistName()
{
var settings = new SpotifyImportSettings
{
Playlists =
{
new SpotifyPlaylistConfig
{
Name = "Discover Weekly",
Id = "spotify-a",
JellyfinId = "jellyfin-a",
UserId = "user-a"
},
new SpotifyPlaylistConfig
{
Name = "Discover Weekly",
Id = "spotify-b",
JellyfinId = "jellyfin-b",
UserId = "user-b"
}
}
};
var resolved = SpotifyPlaylistScopeResolver.ResolveConfig(
settings,
"Discover Weekly",
userId: "user-a",
jellyfinPlaylistId: "jellyfin-b");
Assert.NotNull(resolved);
Assert.Equal("spotify-b", resolved!.Id);
Assert.Equal("jellyfin-b", resolved.JellyfinId);
Assert.Equal("user-b", resolved.UserId);
}
[Fact]
public void GetUserId_PrefersConfiguredValueAndTrimsFallback()
{
var configured = new SpotifyPlaylistConfig
{
UserId = " configured-user "
};
Assert.Equal("configured-user", SpotifyPlaylistScopeResolver.GetUserId(configured, "fallback"));
Assert.Equal("fallback-user", SpotifyPlaylistScopeResolver.GetUserId(null, " fallback-user "));
Assert.Null(SpotifyPlaylistScopeResolver.GetUserId(null, " "));
}
[Fact]
public void GetUserId_DoesNotApplyRequestFallbackToGlobalConfiguredPlaylist()
{
var globalConfiguredPlaylist = new SpotifyPlaylistConfig
{
Name = "On Repeat",
JellyfinId = "7c2b218bd69b00e24c986363ba71852f"
};
Assert.Null(SpotifyPlaylistScopeResolver.GetUserId(
globalConfiguredPlaylist,
"1635cd7d23144ba08251ebe22a56119e"));
}
[Fact]
public void GetScopeId_PrefersJellyfinIdThenSpotifyIdThenFallback()
{
var jellyfinScoped = new SpotifyPlaylistConfig
{
Id = "spotify-id",
JellyfinId = " jellyfin-id "
};
var spotifyScoped = new SpotifyPlaylistConfig
{
Id = " spotify-id "
};
Assert.Equal("jellyfin-id", SpotifyPlaylistScopeResolver.GetScopeId(jellyfinScoped, "fallback"));
Assert.Equal("spotify-id", SpotifyPlaylistScopeResolver.GetScopeId(spotifyScoped, "fallback"));
Assert.Equal("fallback-id", SpotifyPlaylistScopeResolver.GetScopeId(null, " fallback-id "));
Assert.Null(SpotifyPlaylistScopeResolver.GetScopeId(null, " "));
}
}
@@ -85,7 +85,8 @@ public class SquidWTFDownloadServiceTests : IDisposable
var cache = new RedisCacheService(
Options.Create(new RedisSettings { Enabled = false }),
_redisLoggerMock.Object);
_redisLoggerMock.Object,
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
var odesliService = new OdesliService(_httpClientFactoryMock.Object, _odesliLoggerMock.Object, cache);
+64 -1
View File
@@ -1,5 +1,6 @@
using Xunit;
using Moq;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using allstarr.Services.SquidWTF;
@@ -42,7 +43,10 @@ public class SquidWTFMetadataServiceTests
// Create mock Redis cache
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false });
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object);
_mockCache = new Mock<RedisCacheService>(
mockRedisSettings,
mockRedisLogger.Object,
new MemoryCache(new MemoryCacheOptions()));
_apiUrls = new List<string>
{
@@ -299,6 +303,65 @@ public class SquidWTFMetadataServiceTests
Assert.NotNull(result);
}
[Fact]
public async Task SearchAllAsync_WithZeroLimits_SkipsUnusedBuckets()
{
var requestKinds = new List<string>();
var handler = new StubHttpMessageHandler(request =>
{
var trackQuery = GetQueryParameter(request.RequestUri!, "s");
var albumQuery = GetQueryParameter(request.RequestUri!, "al");
var artistQuery = GetQueryParameter(request.RequestUri!, "a");
if (!string.IsNullOrWhiteSpace(trackQuery))
{
requestKinds.Add("song");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(CreateTrackSearchResponse(CreateTrackPayload(1, "Song", "USRC12345678")))
};
}
if (!string.IsNullOrWhiteSpace(albumQuery))
{
requestKinds.Add("album");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(CreateAlbumSearchResponse())
};
}
if (!string.IsNullOrWhiteSpace(artistQuery))
{
requestKinds.Add("artist");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(CreateArtistSearchResponse())
};
}
throw new InvalidOperationException($"Unexpected request URI: {request.RequestUri}");
});
var httpClient = new HttpClient(handler);
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var service = new SquidWTFMetadataService(
_mockHttpClientFactory.Object,
_subsonicSettings,
_squidwtfSettings,
_mockLogger.Object,
_mockCache.Object,
new List<string> { "https://test1.example.com" });
var result = await service.SearchAllAsync("OK Computer", 0, 5, 0);
Assert.Empty(result.Songs);
Assert.Single(result.Albums);
Assert.Empty(result.Artists);
Assert.Equal(new[] { "album" }, requestKinds);
}
[Fact]
public void ExplicitFilter_RespectsSettings()
{
+1 -1
View File
@@ -9,5 +9,5 @@ public static class AppVersion
/// <summary>
/// Current application version.
/// </summary>
public const string Version = "1.4.1";
public const string Version = "1.4.7";
}
+9 -9
View File
@@ -580,7 +580,7 @@ public class ConfigController : ControllerBase
return Ok(new
{
message = "Configuration updated. Restart container to apply changes.",
message = "Configuration updated. Restart Allstarr to apply changes.",
updatedKeys = appliedUpdates,
requiresRestart = true,
envFilePath = _helperService.GetEnvFilePath()
@@ -637,11 +637,11 @@ public class ConfigController : ControllerBase
{
var keysToDelete = new[]
{
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
$"spotify:matched:{playlist.Name}", // Legacy key
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name)
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId)
};
foreach (var key in keysToDelete)
@@ -696,7 +696,7 @@ public class ConfigController : ControllerBase
_logger.LogWarning("Docker socket not available at {Path}", socketPath);
return StatusCode(503, new {
error = "Docker socket not available",
message = "Please restart manually: docker-compose restart allstarr"
message = "Please restart manually: docker restart allstarr"
});
}
@@ -749,7 +749,7 @@ public class ConfigController : ControllerBase
_logger.LogError("Failed to restart container: {StatusCode} - {Body}", response.StatusCode, errorBody);
return StatusCode((int)response.StatusCode, new {
error = "Failed to restart container",
message = "Please restart manually: docker-compose restart allstarr"
message = "Please restart manually: docker restart allstarr"
});
}
}
@@ -758,7 +758,7 @@ public class ConfigController : ControllerBase
_logger.LogError(ex, "Error restarting container");
return StatusCode(500, new {
error = "Failed to restart container",
message = "Please restart manually: docker-compose restart allstarr"
message = "Please restart manually: docker restart allstarr"
});
}
}
@@ -890,7 +890,7 @@ public class ConfigController : ControllerBase
return Ok(new
{
success = true,
message = ".env file imported successfully. Restart the application for changes to take effect."
message = ".env file imported successfully. Restart Allstarr for changes to take effect."
});
}
catch (Exception ex)
+293 -1
View File
@@ -9,7 +9,9 @@ using allstarr.Services.Admin;
using allstarr.Services.Spotify;
using allstarr.Services.Scrobbling;
using allstarr.Services.SquidWTF;
using System.Diagnostics;
using System.Runtime;
using System.Text.Json;
namespace allstarr.Controllers;
@@ -18,6 +20,9 @@ namespace allstarr.Controllers;
[ServiceFilter(typeof(AdminPortFilter))]
public class DiagnosticsController : ControllerBase
{
private const string SquidWtfProbeSearchQuery = "22 Taylor Swift";
private const string SquidWtfProbeTrackId = "227242909";
private const string SquidWtfProbeQuality = "LOW";
private readonly ILogger<DiagnosticsController> _logger;
private readonly IConfiguration _configuration;
private readonly SpotifyApiSettings _spotifyApiSettings;
@@ -29,6 +34,8 @@ public class DiagnosticsController : ControllerBase
private readonly RedisCacheService _cache;
private readonly SpotifySessionCookieService _spotifySessionCookieService;
private readonly List<string> _squidWtfApiUrls;
private readonly List<string> _squidWtfStreamingUrls;
private readonly IHttpClientFactory _httpClientFactory;
private static int _urlIndex = 0;
private static readonly object _urlIndexLock = new();
@@ -43,7 +50,8 @@ public class DiagnosticsController : ControllerBase
IOptions<SquidWTFSettings> squidWtfSettings,
SpotifySessionCookieService spotifySessionCookieService,
SquidWtfEndpointCatalog squidWtfEndpointCatalog,
RedisCacheService cache)
RedisCacheService cache,
IHttpClientFactory httpClientFactory)
{
_logger = logger;
_configuration = configuration;
@@ -56,6 +64,8 @@ public class DiagnosticsController : ControllerBase
_spotifySessionCookieService = spotifySessionCookieService;
_cache = cache;
_squidWtfApiUrls = squidWtfEndpointCatalog.ApiUrls;
_squidWtfStreamingUrls = squidWtfEndpointCatalog.StreamingUrls;
_httpClientFactory = httpClientFactory;
}
[HttpGet("status")]
@@ -161,6 +171,61 @@ public class DiagnosticsController : ControllerBase
return Ok(new { baseUrl });
}
[HttpPost("squidwtf/endpoints/test")]
public async Task<IActionResult> TestSquidWtfEndpoints(CancellationToken cancellationToken)
{
var forbidden = RequireAdministratorForSensitiveOperation("squidwtf endpoint diagnostics");
if (forbidden != null)
{
return forbidden;
}
try
{
var rows = BuildSquidWtfEndpointRows();
_logger.LogInformation(
"Starting SquidWTF endpoint diagnostics for {RowCount} hosts ({ApiCount} API URLs, {StreamingCount} streaming URLs)",
rows.Count,
_squidWtfApiUrls.Count,
_squidWtfStreamingUrls.Count);
var probeTasks = rows.Select(row => PopulateProbeResultsAsync(row, cancellationToken));
await Task.WhenAll(probeTasks);
var apiUpCount = rows.Count(row => row.Api.Configured && row.Api.IsUp);
var streamingUpCount = rows.Count(row => row.Streaming.Configured && row.Streaming.IsUp);
_logger.LogInformation(
"Completed SquidWTF endpoint diagnostics: API up {ApiUp}/{ApiConfigured}, streaming up {StreamingUp}/{StreamingConfigured}",
apiUpCount,
rows.Count(row => row.Api.Configured),
streamingUpCount,
rows.Count(row => row.Streaming.Configured));
var response = new SquidWtfEndpointHealthResponse
{
TestedAtUtc = DateTime.UtcNow,
TotalRows = rows.Count,
Endpoints = rows
.OrderBy(r => r.Host, StringComparer.OrdinalIgnoreCase)
.ToList()
};
return Ok(response);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test SquidWTF endpoints");
return StatusCode(StatusCodes.Status500InternalServerError, new
{
error = "Failed to test SquidWTF endpoints"
});
}
}
/// <summary>
/// Get current configuration including cache settings
/// </summary>
@@ -423,6 +488,233 @@ public class DiagnosticsController : ControllerBase
}
}
private IActionResult? RequireAdministratorForSensitiveOperation(string operationName)
{
if (HttpContext.Items.TryGetValue(AdminAuthSessionService.HttpContextSessionItemKey, out var sessionObj) &&
sessionObj is AdminAuthSession session &&
session.IsAdministrator)
{
return null;
}
_logger.LogWarning("Blocked sensitive admin operation '{Operation}' due to missing administrator session", operationName);
return StatusCode(StatusCodes.Status403Forbidden, new
{
error = "Administrator permissions required",
message = "This operation is restricted to Jellyfin administrators."
});
}
private List<SquidWtfEndpointHealthRow> BuildSquidWtfEndpointRows()
{
var rows = new Dictionary<string, SquidWtfEndpointHealthRow>(StringComparer.OrdinalIgnoreCase);
foreach (var apiUrl in _squidWtfApiUrls)
{
var key = GetEndpointKey(apiUrl);
if (!rows.TryGetValue(key, out var row))
{
row = new SquidWtfEndpointHealthRow
{
Host = key
};
rows[key] = row;
}
row.ApiUrl = apiUrl;
}
foreach (var streamingUrl in _squidWtfStreamingUrls)
{
var key = GetEndpointKey(streamingUrl);
if (!rows.TryGetValue(key, out var row))
{
row = new SquidWtfEndpointHealthRow
{
Host = key
};
rows[key] = row;
}
row.StreamingUrl = streamingUrl;
}
return rows.Values.ToList();
}
private async Task PopulateProbeResultsAsync(SquidWtfEndpointHealthRow row, CancellationToken cancellationToken)
{
var apiTask = ProbeApiEndpointAsync(row.ApiUrl, cancellationToken);
var streamingTask = ProbeStreamingEndpointAsync(row.StreamingUrl, cancellationToken);
await Task.WhenAll(apiTask, streamingTask);
row.Api = await apiTask;
row.Streaming = await streamingTask;
var anyFailure = (row.Api.Configured && !row.Api.IsUp) ||
(row.Streaming.Configured && !row.Streaming.IsUp);
_logger.Log(
anyFailure ? LogLevel.Warning : LogLevel.Information,
"SquidWTF probe {Host}: API {ApiState} ({ApiStatusCode}, {ApiLatencyMs}ms{ApiErrorSuffix}) | streaming {StreamingState} ({StreamingStatusCode}, {StreamingLatencyMs}ms{StreamingErrorSuffix})",
row.Host,
row.Api.State,
row.Api.StatusCode?.ToString() ?? "n/a",
row.Api.LatencyMs?.ToString() ?? "n/a",
string.IsNullOrWhiteSpace(row.Api.Error) ? string.Empty : $", {row.Api.Error}",
row.Streaming.State,
row.Streaming.StatusCode?.ToString() ?? "n/a",
row.Streaming.LatencyMs?.ToString() ?? "n/a",
string.IsNullOrWhiteSpace(row.Streaming.Error) ? string.Empty : $", {row.Streaming.Error}");
}
private async Task<SquidWtfEndpointProbeResult> ProbeApiEndpointAsync(string? baseUrl, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(baseUrl))
{
return new SquidWtfEndpointProbeResult
{
Configured = false,
State = "missing",
Error = "No API URL configured"
};
}
var requestUrl = $"{baseUrl}/search/?s={Uri.EscapeDataString(SquidWtfProbeSearchQuery)}&limit=1&offset=0";
return await ProbeEndpointAsync(
requestUrl,
response => ResponseContainsSearchItemsAsync(response, cancellationToken),
cancellationToken);
}
private async Task<SquidWtfEndpointProbeResult> ProbeStreamingEndpointAsync(string? baseUrl, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(baseUrl))
{
return new SquidWtfEndpointProbeResult
{
Configured = false,
State = "missing",
Error = "No streaming URL configured"
};
}
var requestUrl = $"{baseUrl}/track/?id={Uri.EscapeDataString(SquidWtfProbeTrackId)}&quality={Uri.EscapeDataString(SquidWtfProbeQuality)}";
return await ProbeEndpointAsync(
requestUrl,
response => ResponseContainsTrackManifestAsync(response, cancellationToken),
cancellationToken);
}
private async Task<SquidWtfEndpointProbeResult> ProbeEndpointAsync(
string requestUrl,
Func<HttpResponseMessage, Task<bool>> isHealthyResponse,
CancellationToken cancellationToken)
{
using var client = CreateDiagnosticsHttpClient();
using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
var stopwatch = Stopwatch.StartNew();
try
{
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
stopwatch.Stop();
var isHealthy = response.IsSuccessStatusCode && await isHealthyResponse(response);
return new SquidWtfEndpointProbeResult
{
Configured = true,
IsUp = isHealthy,
State = isHealthy ? "up" : "down",
StatusCode = (int)response.StatusCode,
LatencyMs = stopwatch.ElapsedMilliseconds,
RequestUrl = requestUrl,
Error = isHealthy ? null : $"Unexpected {(int)response.StatusCode} response"
};
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
stopwatch.Stop();
return new SquidWtfEndpointProbeResult
{
Configured = true,
State = "timeout",
LatencyMs = stopwatch.ElapsedMilliseconds,
RequestUrl = requestUrl,
Error = "Timed out"
};
}
catch (HttpRequestException ex)
{
stopwatch.Stop();
return new SquidWtfEndpointProbeResult
{
Configured = true,
State = "down",
StatusCode = ex.StatusCode.HasValue ? (int)ex.StatusCode.Value : null,
LatencyMs = stopwatch.ElapsedMilliseconds,
RequestUrl = requestUrl,
Error = ex.Message
};
}
catch (Exception ex)
{
stopwatch.Stop();
return new SquidWtfEndpointProbeResult
{
Configured = true,
State = "down",
LatencyMs = stopwatch.ElapsedMilliseconds,
RequestUrl = requestUrl,
Error = ex.Message
};
}
}
private HttpClient CreateDiagnosticsHttpClient()
{
var client = _httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(8);
if (!client.DefaultRequestHeaders.UserAgent.Any())
{
client.DefaultRequestHeaders.UserAgent.ParseAdd("allstarr-admin-diagnostics/1.0");
}
return client;
}
private static async Task<bool> ResponseContainsSearchItemsAsync(
HttpResponseMessage response,
CancellationToken cancellationToken)
{
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
return document.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("items", out var items) &&
items.ValueKind == JsonValueKind.Array;
}
private static async Task<bool> ResponseContainsTrackManifestAsync(
HttpResponseMessage response,
CancellationToken cancellationToken)
{
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
return document.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("manifest", out var manifest) &&
manifest.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(manifest.GetString());
}
private static string GetEndpointKey(string url)
{
if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
return uri.IsDefaultPort ? uri.Host : $"{uri.Host}:{uri.Port}";
}
return url.Trim();
}
/// <summary>
+149 -8
View File
@@ -1,9 +1,12 @@
using System.Text.Json;
using System.Text;
using System.Net.Http;
using allstarr.Models.Domain;
using allstarr.Models.Settings;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Http.Features;
namespace allstarr.Controllers;
@@ -11,6 +14,20 @@ public partial class JellyfinController
{
#region Helpers
private static readonly HashSet<string> PassthroughResponseHeadersToSkip = new(StringComparer.OrdinalIgnoreCase)
{
"Connection",
"Keep-Alive",
"Proxy-Authenticate",
"Proxy-Authorization",
"TE",
"Trailer",
"Transfer-Encoding",
"Upgrade",
"Content-Type",
"Content-Length"
};
/// <summary>
/// Helper to handle proxy responses with proper status code handling.
/// </summary>
@@ -48,6 +65,60 @@ public partial class JellyfinController
return NoContent();
}
private async Task<IActionResult> ProxyJsonPassthroughAsync(string endpoint)
{
try
{
// Match the previous proxy semantics for client compatibility.
// Some Jellyfin clients/proxies cancel the ASP.NET request token aggressively
// even though the upstream request would still complete successfully.
var upstreamResponse = await _proxyService.GetPassthroughResponseAsync(
endpoint,
Request.Headers);
HttpContext.Response.RegisterForDispose(upstreamResponse);
HttpContext.Features.Get<IHttpResponseBodyFeature>()?.DisableBuffering();
Response.StatusCode = (int)upstreamResponse.StatusCode;
Response.Headers["X-Accel-Buffering"] = "no";
CopyPassthroughResponseHeaders(upstreamResponse);
if (upstreamResponse.Content.Headers.ContentLength.HasValue)
{
Response.ContentLength = upstreamResponse.Content.Headers.ContentLength.Value;
}
var contentType = upstreamResponse.Content.Headers.ContentType?.ToString() ?? "application/json";
var stream = await upstreamResponse.Content.ReadAsStreamAsync();
return new FileStreamResult(stream, contentType);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to transparently proxy Jellyfin request for {Endpoint}", endpoint);
return StatusCode(502, new { error = "Failed to connect to Jellyfin server" });
}
}
private void CopyPassthroughResponseHeaders(HttpResponseMessage upstreamResponse)
{
foreach (var header in upstreamResponse.Headers)
{
if (!PassthroughResponseHeadersToSkip.Contains(header.Key))
{
Response.Headers[header.Key] = header.Value.ToArray();
}
}
foreach (var header in upstreamResponse.Content.Headers)
{
if (!PassthroughResponseHeadersToSkip.Contains(header.Key))
{
Response.Headers[header.Key] = header.Value.ToArray();
}
}
}
/// <summary>
/// Updates ChildCount for Spotify playlists in the response to show total tracks (local + matched).
/// </summary>
@@ -97,7 +168,7 @@ public partial class JellyfinController
if (!spotifyPlaylistCreatedDates.TryGetValue(playlistName, out var playlistCreatedDate))
{
playlistCreatedDate = await ResolveSpotifyPlaylistCreatedDateAsync(playlistName);
playlistCreatedDate = await ResolveSpotifyPlaylistCreatedDateAsync(playlistConfig);
spotifyPlaylistCreatedDates[playlistName] = playlistCreatedDate;
}
@@ -107,7 +178,16 @@ public partial class JellyfinController
}
// Get matched external tracks (tracks that were successfully downloaded/matched)
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName);
var playlistScopeUserId = string.IsNullOrWhiteSpace(playlistConfig.UserId)
? null
: playlistConfig.UserId.Trim();
var playlistScopeId = !string.IsNullOrWhiteSpace(playlistConfig.JellyfinId)
? playlistConfig.JellyfinId
: playlistConfig.Id;
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
_logger.LogDebug("Cache lookup for {Key}: {Count} matched tracks",
@@ -116,7 +196,10 @@ public partial class JellyfinController
// Fallback to legacy cache format
if (matchedTracks == null || matchedTracks.Count == 0)
{
var legacyKey = $"spotify:matched:{playlistName}";
var legacyKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
var legacySongs = await _cache.GetAsync<List<Song>>(legacyKey);
if (legacySongs != null && legacySongs.Count > 0)
{
@@ -132,7 +215,10 @@ public partial class JellyfinController
// Prefer the currently served playlist items cache when available.
// This most closely matches what the injected playlist endpoint will return.
var exactServedCount = 0;
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlistName);
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
var cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsKey);
var exactServedRunTimeTicks = 0L;
if (cachedPlaylistItems != null &&
@@ -161,7 +247,7 @@ public partial class JellyfinController
var localRunTimeTicks = 0L;
try
{
var userId = _settings.UserId;
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
var playlistItemsUrl = $"Playlists/{playlistId}/Items";
var queryParams = new Dictionary<string, string>
{
@@ -264,11 +350,22 @@ public partial class JellyfinController
}
}
private async Task<DateTime?> ResolveSpotifyPlaylistCreatedDateAsync(string playlistName)
private async Task<DateTime?> ResolveSpotifyPlaylistCreatedDateAsync(SpotifyPlaylistConfig playlistConfig)
{
var playlistName = playlistConfig.Name;
try
{
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName);
var playlistScopeUserId = string.IsNullOrWhiteSpace(playlistConfig.UserId)
? null
: playlistConfig.UserId.Trim();
var playlistScopeId = !string.IsNullOrWhiteSpace(playlistConfig.JellyfinId)
? playlistConfig.JellyfinId
: playlistConfig.Id;
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
var cachedPlaylist = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
var createdAt = GetCreatedDateFromSpotifyPlaylist(cachedPlaylist);
if (createdAt.HasValue)
@@ -281,7 +378,10 @@ public partial class JellyfinController
return null;
}
var tracks = await _spotifyPlaylistFetcher.GetPlaylistTracksAsync(playlistName);
var tracks = await _spotifyPlaylistFetcher.GetPlaylistTracksAsync(
playlistName,
playlistScopeUserId,
playlistConfig.JellyfinId);
var earliestTrackAddedAt = tracks
.Where(t => t.AddedAt.HasValue)
.Select(t => t.AddedAt!.Value.ToUniversalTime())
@@ -407,6 +507,47 @@ public partial class JellyfinController
return includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static string? GetExactPlaylistItemsRequestId(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 3 ||
!parts[0].Equals("playlists", StringComparison.OrdinalIgnoreCase) ||
!parts[2].Equals("items", StringComparison.OrdinalIgnoreCase))
{
return null;
}
return parts[1];
}
private static string? ExtractImageTag(JsonElement item, string imageType)
{
if (item.TryGetProperty("ImageTags", out var imageTags) &&
imageTags.ValueKind == JsonValueKind.Object)
{
foreach (var imageTag in imageTags.EnumerateObject())
{
if (string.Equals(imageTag.Name, imageType, StringComparison.OrdinalIgnoreCase))
{
return imageTag.Value.GetString();
}
}
}
if (string.Equals(imageType, "Primary", StringComparison.OrdinalIgnoreCase) &&
item.TryGetProperty("PrimaryImageTag", out var primaryImageTag))
{
return primaryImageTag.GetString();
}
return null;
}
/// <summary>
/// Determines whether Spotify playlist count enrichment should run for a response.
/// We only run enrichment for playlist-oriented payloads to avoid mutating unrelated item lists
@@ -39,69 +39,9 @@ public partial class JellyfinController
{
var responseJson = result.RootElement.GetRawText();
// On successful auth, extract access token and post session capabilities in background
if (statusCode == 200)
{
_logger.LogInformation("Authentication successful");
// Extract access token from response for session capabilities
string? accessToken = null;
if (result.RootElement.TryGetProperty("AccessToken", out var tokenEl))
{
accessToken = tokenEl.GetString();
}
// Post session capabilities in background if we have a token
if (!string.IsNullOrEmpty(accessToken))
{
var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers);
// Capture token in closure - don't use Request.Headers (will be disposed)
var token = accessToken;
var authHeader = AuthHeaderHelper.CreateAuthHeader(token, client, device, deviceId, version);
_ = Task.Run(async () =>
{
try
{
_logger.LogDebug("🔧 Posting session capabilities after authentication");
// Build auth header with the new token
var authHeaders = new HeaderDictionary
{
["X-Emby-Authorization"] = authHeader,
["X-Emby-Token"] = token
};
var capabilities = new
{
PlayableMediaTypes = new[] { "Audio" },
SupportedCommands = Array.Empty<string>(),
SupportsMediaControl = false,
SupportsPersistentIdentifier = true,
SupportsSync = false
};
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
var (capResult, capStatus) =
await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", capabilitiesJson,
authHeaders);
if (capStatus == 204 || capStatus == 200)
{
_logger.LogDebug("✓ Session capabilities posted after auth ({StatusCode})",
capStatus);
}
else
{
_logger.LogDebug("⚠ Session capabilities returned {StatusCode} after auth",
capStatus);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to post session capabilities after auth");
}
});
}
}
else
{
@@ -1,7 +1,9 @@
using System.Collections.Concurrent;
using System.Text.Json;
using System.Globalization;
using allstarr.Models.Jellyfin;
using allstarr.Models.Scrobbling;
using allstarr.Serialization;
using Microsoft.AspNetCore.Mvc;
namespace allstarr.Controllers;
@@ -226,7 +228,7 @@ public partial class JellyfinController
// Build minimal playback start with just the ghost UUID
// Don't include the Item object - Jellyfin will just track the session without item details
var playbackStart = new
var playbackStart = new JellyfinPlaybackStatePayload
{
ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0,
@@ -236,7 +238,7 @@ public partial class JellyfinController
PlayMethod = "DirectPlay"
};
var playbackJson = JsonSerializer.Serialize(playbackStart);
var playbackJson = AllstarrJsonSerializer.Serialize(playbackStart);
_logger.LogDebug("📤 Sending ghost playback start for external track: {Json}", playbackJson);
// Forward to Jellyfin with ghost UUID
@@ -357,14 +359,13 @@ public partial class JellyfinController
trackName ?? "Unknown", itemId);
// Build playback start info - Jellyfin will fetch item details itself
var playbackStart = new
var playbackStart = new JellyfinPlaybackStatePayload
{
ItemId = itemId,
PositionTicks = positionTicks ?? 0,
// Let Jellyfin fetch the item details - don't include NowPlayingItem
ItemId = itemId ?? string.Empty,
PositionTicks = positionTicks ?? 0
};
var playbackJson = JsonSerializer.Serialize(playbackStart);
var playbackJson = AllstarrJsonSerializer.Serialize(playbackStart);
_logger.LogDebug("📤 Sending playback start: {Json}", playbackJson);
var (result, statusCode) =
@@ -624,7 +625,7 @@ public partial class JellyfinController
externalId);
var inferredStartGhostUuid = GenerateUuidFromString(itemId);
var inferredExternalStartPayload = JsonSerializer.Serialize(new
var inferredExternalStartPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
{
ItemId = inferredStartGhostUuid,
PositionTicks = positionTicks ?? 0,
@@ -692,7 +693,7 @@ public partial class JellyfinController
var ghostUuid = GenerateUuidFromString(itemId);
// Build progress report with ghost UUID
var progressReport = new
var progressReport = new JellyfinPlaybackStatePayload
{
ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0,
@@ -702,7 +703,7 @@ public partial class JellyfinController
PlayMethod = "DirectPlay"
};
var progressJson = JsonSerializer.Serialize(progressReport);
var progressJson = AllstarrJsonSerializer.Serialize(progressReport);
// Forward to Jellyfin with ghost UUID
var (progressResult, progressStatusCode) =
@@ -773,7 +774,7 @@ public partial class JellyfinController
_logger.LogInformation("🎵 Local track playback started (inferred from progress): {Name} (ID: {ItemId})",
trackName ?? "Unknown", itemId);
var inferredStartPayload = JsonSerializer.Serialize(new
var inferredStartPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
{
ItemId = itemId,
PositionTicks = positionTicks ?? 0
@@ -948,7 +949,7 @@ public partial class JellyfinController
}
var ghostUuid = GenerateUuidFromString(previousItemId);
var inferredExternalStopPayload = JsonSerializer.Serialize(new
var inferredExternalStopPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
{
ItemId = ghostUuid,
PositionTicks = previousPositionTicks ?? 0,
@@ -997,7 +998,7 @@ public partial class JellyfinController
});
}
var inferredStopPayload = JsonSerializer.Serialize(new
var inferredStopPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
{
ItemId = previousItemId,
PositionTicks = previousPositionTicks ?? 0,
@@ -1062,7 +1063,7 @@ public partial class JellyfinController
return;
}
var userId = ResolvePlaybackUserId(progressPayload);
var userId = await ResolvePlaybackUserIdAsync(progressPayload);
if (string.IsNullOrWhiteSpace(userId))
{
_logger.LogDebug("Skipping local played signal for {ItemId} - no user id available", itemId);
@@ -1098,7 +1099,7 @@ public partial class JellyfinController
}
}
private string? ResolvePlaybackUserId(JsonElement progressPayload)
private async Task<string?> ResolvePlaybackUserIdAsync(JsonElement progressPayload)
{
if (progressPayload.TryGetProperty("UserId", out var userIdElement) &&
userIdElement.ValueKind == JsonValueKind.String)
@@ -1116,7 +1117,7 @@ public partial class JellyfinController
return queryUserId;
}
return _settings.UserId;
return await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
}
private static int? ToPlaybackPositionSeconds(long? positionTicks)
@@ -1294,13 +1295,13 @@ public partial class JellyfinController
// Report stop to Jellyfin with ghost UUID
var ghostUuid = GenerateUuidFromString(itemId);
var externalStopInfo = new
var externalStopInfo = new JellyfinPlaybackStatePayload
{
ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0
};
var stopJson = JsonSerializer.Serialize(externalStopInfo);
var stopJson = AllstarrJsonSerializer.Serialize(externalStopInfo);
_logger.LogDebug("📤 Sending ghost playback stop for external track: {Json}", stopJson);
var (stopResult, stopStatusCode) =
@@ -1469,7 +1470,7 @@ public partial class JellyfinController
stopInfo["PositionTicks"] = positionTicks.Value;
}
body = JsonSerializer.Serialize(stopInfo);
body = AllstarrJsonSerializer.Serialize(stopInfo);
_logger.LogDebug("📤 Sending playback stop body (IsPaused=false, {BodyLength} bytes)", body.Length);
var (result, statusCode) =
@@ -1558,9 +1559,14 @@ public partial class JellyfinController
string.Join(", ", Request.Headers.Keys.Where(h =>
h.Contains("Auth", StringComparison.OrdinalIgnoreCase))));
// Read body if present
string body = "{}";
if ((method == "POST" || method == "PUT") && Request.ContentLength > 0)
// Read body if present. Preserve true empty-body requests because Jellyfin
// uses several POST session-control endpoints with query params only.
string? body = null;
var hasRequestBody = !HttpMethods.IsGet(method) &&
(Request.ContentLength.GetValueOrDefault() > 0 ||
Request.Headers.ContainsKey("Transfer-Encoding"));
if (hasRequestBody)
{
Request.EnableBuffering();
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8,
@@ -1577,9 +1583,9 @@ public partial class JellyfinController
var (result, statusCode) = method switch
{
"GET" => await _proxyService.GetJsonAsync(endpoint, null, Request.Headers),
"POST" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers),
"PUT" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers), // Use POST for PUT
"DELETE" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers), // Use POST for DELETE
"POST" => await _proxyService.SendAsync(HttpMethod.Post, endpoint, body, Request.Headers, Request.ContentType),
"PUT" => await _proxyService.SendAsync(HttpMethod.Put, endpoint, body, Request.Headers, Request.ContentType),
"DELETE" => await _proxyService.SendAsync(HttpMethod.Delete, endpoint, body, Request.Headers, Request.ContentType),
_ => (null, 405)
};
+347 -309
View File
@@ -1,7 +1,11 @@
using System.Buffers;
using System.Text.Json;
using System.Text;
using allstarr.Models.Domain;
using allstarr.Models.Jellyfin;
using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using allstarr.Serialization;
using allstarr.Services.Common;
using Microsoft.AspNetCore.Mvc;
@@ -24,6 +28,7 @@ public partial class JellyfinController
[FromQuery] int startIndex = 0,
[FromQuery] string? parentId = null,
[FromQuery] string? artistIds = null,
[FromQuery] string? contributingArtistIds = null,
[FromQuery] string? albumArtistIds = null,
[FromQuery] string? albumIds = null,
[FromQuery] string? sortBy = null,
@@ -32,13 +37,14 @@ public partial class JellyfinController
{
var boundSearchTerm = searchTerm;
searchTerm = GetEffectiveSearchTerm(searchTerm, Request.QueryString.Value);
string? searchCacheKey = null;
// AlbumArtistIds takes precedence over ArtistIds if both are provided
var effectiveArtistIds = albumArtistIds ?? artistIds;
var favoritesOnlyRequest = IsFavoritesOnlyRequest();
_logger.LogDebug("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, albumArtistIds={AlbumArtistIds}, albumIds={AlbumIds}, userId={UserId}",
searchTerm, includeItemTypes, parentId, artistIds, albumArtistIds, albumIds, userId);
_logger.LogDebug("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, contributingArtistIds={ContributingArtistIds}, albumArtistIds={AlbumArtistIds}, albumIds={AlbumIds}, userId={UserId}",
searchTerm, includeItemTypes, parentId, artistIds, contributingArtistIds, albumArtistIds, albumIds, userId);
_logger.LogInformation(
"SEARCH TRACE: rawQuery='{RawQuery}', boundSearchTerm='{BoundSearchTerm}', effectiveSearchTerm='{EffectiveSearchTerm}', includeItemTypes='{IncludeItemTypes}'",
Request.QueryString.Value ?? string.Empty,
@@ -49,15 +55,34 @@ public partial class JellyfinController
// ============================================================================
// REQUEST ROUTING LOGIC (Priority Order)
// ============================================================================
// 1. ArtistIds present (external) → Handle external artists (even with ParentId)
// 2. AlbumIds present (external) → Handle external albums (even with ParentId)
// 3. ParentId present → GetChildItems (handles external playlists/albums/artists OR proxies library items)
// 4. ArtistIds present (library) → Proxy to Jellyfin with artist filter
// 5. SearchTerm present → Integrated search (Jellyfin + external sources)
// 6. Otherwise → Proxy browse request transparently to Jellyfin
// 1. ContributingArtistIds present (external) → Handle external "appears on" albums
// 2. ArtistIds present (external) → Handle external artists (even with ParentId)
// 3. AlbumIds present (external) → Handle external albums (even with ParentId)
// 4. ParentId present → GetChildItems (handles external playlists/albums/artists OR proxies library items)
// 5. ArtistIds / ContributingArtistIds present (library) → Proxy to Jellyfin with full filter
// 6. SearchTerm present → Integrated search (Jellyfin + external sources)
// 7. Otherwise → Proxy browse request transparently to Jellyfin
// ============================================================================
// PRIORITY 1: External artist filter - takes precedence over everything (including ParentId)
// PRIORITY 1: External contributing artist filter - used by Jellyfin's "Appears on" album requests.
if (!string.IsNullOrWhiteSpace(contributingArtistIds))
{
var contributingArtistId = contributingArtistIds.Split(',')[0];
var (isExternal, provider, type, externalId) = _localLibraryService.ParseExternalId(contributingArtistId);
if (isExternal)
{
_logger.LogDebug(
"Fetching contributing artist albums for external artist: {Provider}/{ExternalId}, type={Type}",
provider,
externalId,
type);
return await GetExternalContributorChildItems(provider!, type!, externalId!, includeItemTypes, HttpContext.RequestAborted);
}
// If library artist, fall through to proxy
}
// PRIORITY 2: External artist filter - takes precedence over everything (including ParentId)
if (!string.IsNullOrWhiteSpace(effectiveArtistIds))
{
var artistId = effectiveArtistIds.Split(',')[0]; // Take first artist if multiple
@@ -85,7 +110,7 @@ public partial class JellyfinController
// If library artist, fall through to handle with ParentId or proxy
}
// PRIORITY 2: External album filter
// PRIORITY 3: External album filter
if (!string.IsNullOrWhiteSpace(albumIds))
{
var albumId = albumIds.Split(',')[0]; // Take first album if multiple
@@ -105,23 +130,17 @@ public partial class JellyfinController
var album = await _metadataService.GetAlbumAsync(provider!, externalId!, HttpContext.RequestAborted);
if (album == null)
{
return new JsonResult(new
{ Items = Array.Empty<object>(), TotalRecordCount = 0, StartIndex = startIndex });
return CreateItemsResponse([], 0, startIndex);
}
var albumItems = album.Songs.Select(song => _responseBuilder.ConvertSongToJellyfinItem(song)).ToList();
return new JsonResult(new
{
Items = albumItems,
TotalRecordCount = albumItems.Count,
StartIndex = startIndex
});
return CreateItemsResponse(albumItems, albumItems.Count, startIndex);
}
// If library album, fall through to handle with ParentId or proxy
}
// PRIORITY 3: ParentId present - check if external first
// PRIORITY 4: ParentId present - check if external first
if (!string.IsNullOrWhiteSpace(parentId))
{
// Check if this is an external playlist
@@ -163,7 +182,17 @@ public partial class JellyfinController
}
}
// PRIORITY 4: Library artist filter (already checked for external above)
// PRIORITY 5: Library artist/contributing-artist filters (already checked for external above)
if (!string.IsNullOrWhiteSpace(contributingArtistIds))
{
_logger.LogDebug("Library contributing artist filter requested, proxying to Jellyfin");
var endpoint = userId != null
? $"Users/{userId}/Items{Request.QueryString}"
: $"Items{Request.QueryString}";
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
return HandleProxyResponse(result, statusCode);
}
if (!string.IsNullOrWhiteSpace(effectiveArtistIds))
{
// Library artist - proxy transparently with full query string
@@ -175,13 +204,13 @@ public partial class JellyfinController
return HandleProxyResponse(result, statusCode);
}
// PRIORITY 5: Search term present - do integrated search (Jellyfin + external)
// PRIORITY 6: Search term present - do integrated search (Jellyfin + external)
if (!string.IsNullOrWhiteSpace(searchTerm))
{
// Check cache for search results (only cache pure searches, not filtered searches)
if (string.IsNullOrWhiteSpace(effectiveArtistIds) && string.IsNullOrWhiteSpace(albumIds))
{
var cacheKey = CacheKeyBuilder.BuildSearchKey(
searchCacheKey = CacheKeyBuilder.BuildSearchKey(
searchTerm,
includeItemTypes,
limit,
@@ -192,18 +221,18 @@ public partial class JellyfinController
recursive,
userId,
Request.Query["IsFavorite"].ToString());
var cachedResult = await _cache.GetAsync<object>(cacheKey);
var cachedResult = await _cache.GetStringAsync(searchCacheKey);
if (cachedResult != null)
if (!string.IsNullOrWhiteSpace(cachedResult))
{
_logger.LogInformation("SEARCH TRACE: cache hit for key '{CacheKey}'", cacheKey);
return new JsonResult(cachedResult);
_logger.LogInformation("SEARCH TRACE: cache hit for key '{CacheKey}'", searchCacheKey);
return Content(cachedResult, "application/json");
}
}
// Fall through to integrated search below
}
// PRIORITY 6: No filters, no search - proxy browse request transparently
// PRIORITY 7: No filters, no search - proxy browse request transparently
else
{
_logger.LogDebug("Browse request with no filters, proxying to Jellyfin with full query string");
@@ -303,6 +332,7 @@ public partial class JellyfinController
// Run local and external searches in parallel
var itemTypes = ParseItemTypes(includeItemTypes);
var externalSearchLimits = GetExternalSearchLimits(itemTypes, limit, includePlaylistsAsAlbums: true);
var jellyfinTask = GetLocalSearchResultForCurrentRequest(
cleanQuery,
includeItemTypes,
@@ -311,12 +341,29 @@ public partial class JellyfinController
recursive,
userId);
_logger.LogInformation(
"SEARCH TRACE: external limits for query '{Query}' => songs={SongLimit}, albums={AlbumLimit}, artists={ArtistLimit}",
cleanQuery,
externalSearchLimits.SongLimit,
externalSearchLimits.AlbumLimit,
externalSearchLimits.ArtistLimit);
// Use parallel metadata service if available (races providers), otherwise use primary
var externalTask = favoritesOnlyRequest
? Task.FromResult(new SearchResult())
: _parallelMetadataService != null
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted)
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted);
? _parallelMetadataService.SearchAllAsync(
cleanQuery,
externalSearchLimits.SongLimit,
externalSearchLimits.AlbumLimit,
externalSearchLimits.ArtistLimit,
HttpContext.RequestAborted)
: _metadataService.SearchAllAsync(
cleanQuery,
externalSearchLimits.SongLimit,
externalSearchLimits.AlbumLimit,
externalSearchLimits.ArtistLimit,
HttpContext.RequestAborted);
var playlistTask = favoritesOnlyRequest || !_settings.EnableExternalPlaylists
? Task.FromResult(new List<ExternalPlaylist>())
@@ -384,11 +431,11 @@ public partial class JellyfinController
var externalAlbumItems = externalResult.Albums.Select(a => _responseBuilder.ConvertAlbumToJellyfinItem(a)).ToList();
var externalArtistItems = externalResult.Artists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
// Score-sort each source, then interleave by highest remaining score.
// Keep only a small source preference for already-relevant primary results.
var allSongs = InterleaveByScore(jellyfinSongItems, externalSongItems, cleanQuery, primaryBoost: 1.5, boostMinScore: 72);
var allAlbums = InterleaveByScore(jellyfinAlbumItems, externalAlbumItems, cleanQuery, primaryBoost: 1.5, boostMinScore: 78);
var allArtists = InterleaveByScore(jellyfinArtistItems, externalArtistItems, cleanQuery, primaryBoost: 1.5, boostMinScore: 75);
// Keep Jellyfin/provider ordering intact.
// Scores only decide which source leads each interleaving round.
var allSongs = InterleaveByScore(jellyfinSongItems, externalSongItems, cleanQuery, primaryBoost: 5.0);
var allAlbums = InterleaveByScore(jellyfinAlbumItems, externalAlbumItems, cleanQuery, primaryBoost: 5.0);
var allArtists = InterleaveByScore(jellyfinArtistItems, externalArtistItems, cleanQuery, primaryBoost: 5.0);
// Log top results for debugging
if (_logger.IsEnabled(LogLevel.Debug))
@@ -437,13 +484,8 @@ public partial class JellyfinController
_logger.LogDebug("No playlists found to merge with albums");
}
// Merge albums and playlists using score-based interleaving (albums keep a light priority over playlists).
var mergedAlbumsAndPlaylists = InterleaveByScore(allAlbums, mergedPlaylistItems, cleanQuery, primaryBoost: 2.0, boostMinScore: 70);
mergedAlbumsAndPlaylists = ApplyRequestedAlbumOrderingIfApplicable(
mergedAlbumsAndPlaylists,
itemTypes,
Request.Query["SortBy"].ToString(),
Request.Query["SortOrder"].ToString());
// Keep album/playlist source ordering intact and only let scores decide who leads each round.
var mergedAlbumsAndPlaylists = InterleaveByScore(allAlbums, mergedPlaylistItems, cleanQuery, primaryBoost: 0.0);
_logger.LogDebug(
"Merged results: Songs={Songs}, Albums+Playlists={AlbumsPlaylists}, Artists={Artists}",
@@ -531,58 +573,20 @@ public partial class JellyfinController
try
{
// Return with PascalCase - use ContentResult to bypass JSON serialization issues
var response = new
var response = new JellyfinItemsResponse
{
Items = pagedItems,
TotalRecordCount = items.Count,
StartIndex = startIndex
};
// Cache search results in Redis using the configured search TTL.
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(effectiveArtistIds))
{
if (externalHasRequestedTypeResults)
{
var cacheKey = CacheKeyBuilder.BuildSearchKey(
return await WriteSearchItemsResponseAsync(
response,
searchTerm,
includeItemTypes,
limit,
startIndex,
parentId,
sortBy,
Request.Query["SortOrder"].ToString(),
recursive,
userId,
Request.Query["IsFavorite"].ToString());
await _cache.SetAsync(cacheKey, response, 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})",
effectiveArtistIds,
searchCacheKey,
externalHasRequestedTypeResults,
cleanQuery,
includeItemTypes ?? string.Empty);
}
}
_logger.LogDebug("About to serialize response...");
var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions
{
PropertyNamingPolicy = null,
DictionaryKeyPolicy = null
});
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");
includeItemTypes);
}
catch (Exception ex)
{
@@ -591,6 +595,49 @@ public partial class JellyfinController
}
}
private static string SerializeSearchResponseJson(JellyfinItemsResponse response)
{
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)
{
await _cache.SetStringAsync(searchCacheKey!, json, CacheExtensions.SearchResultsTTL);
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm,
CacheExtensions.SearchResultsTTL.TotalMinutes);
}
else if (!string.IsNullOrWhiteSpace(searchTerm) &&
string.IsNullOrWhiteSpace(effectiveArtistIds) &&
!string.IsNullOrWhiteSpace(searchCacheKey))
{
_logger.LogInformation(
"SEARCH TRACE: skipped cache write for query '{Query}' because requested external result buckets were empty (types={ItemTypes})",
cleanQuery,
includeItemTypes ?? string.Empty);
}
return new EmptyResult();
}
/// <summary>
/// Gets child items of a parent (tracks in album, albums for artist).
/// </summary>
@@ -681,11 +728,33 @@ public partial class JellyfinController
}
var cleanQuery = searchTerm.Trim().Trim('"');
var requestedTypes = ParseItemTypes(includeItemTypes);
var externalSearchLimits = GetExternalSearchLimits(requestedTypes, limit, includePlaylistsAsAlbums: false);
var includesSongs = requestedTypes == null || requestedTypes.Length == 0 || requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
var includesAlbums = requestedTypes == null || requestedTypes.Length == 0 || requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase);
var includesArtists = requestedTypes == null || requestedTypes.Length == 0 || requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
_logger.LogInformation(
"SEARCH TRACE: hint limits for query '{Query}' => songs={SongLimit}, albums={AlbumLimit}, artists={ArtistLimit}",
cleanQuery,
externalSearchLimits.SongLimit,
externalSearchLimits.AlbumLimit,
externalSearchLimits.ArtistLimit);
// Use parallel metadata service if available (races providers), otherwise use primary
var externalTask = _parallelMetadataService != null
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted)
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted);
? _parallelMetadataService.SearchAllAsync(
cleanQuery,
externalSearchLimits.SongLimit,
externalSearchLimits.AlbumLimit,
externalSearchLimits.ArtistLimit,
HttpContext.RequestAborted)
: _metadataService.SearchAllAsync(
cleanQuery,
externalSearchLimits.SongLimit,
externalSearchLimits.AlbumLimit,
externalSearchLimits.ArtistLimit,
HttpContext.RequestAborted);
// Run searches in parallel (local Jellyfin hints + external providers)
var jellyfinTask = GetLocalSearchHintsResultForCurrentRequest(cleanQuery, userId);
@@ -698,9 +767,15 @@ public partial class JellyfinController
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseSearchHintsResponse(jellyfinResult);
// NO deduplication - merge all results and take top matches
var allSongs = localSongs.Concat(externalResult.Songs).Take(limit).ToList();
var allAlbums = localAlbums.Concat(externalResult.Albums).Take(limit).ToList();
var allArtists = localArtists.Concat(externalResult.Artists).Take(limit).ToList();
var allSongs = includesSongs
? localSongs.Concat(externalResult.Songs).Take(limit).ToList()
: new List<Song>();
var allAlbums = includesAlbums
? localAlbums.Concat(externalResult.Albums).Take(limit).ToList()
: new List<Album>();
var allArtists = includesArtists
? localArtists.Concat(externalResult.Artists).Take(limit).ToList()
: new List<Artist>();
return _responseBuilder.CreateSearchHintsResponse(
allSongs.Take(limit).ToList(),
@@ -751,14 +826,91 @@ public partial class JellyfinController
return string.Equals(Request.Query["IsFavorite"].ToString(), "true", StringComparison.OrdinalIgnoreCase);
}
private static (int SongLimit, int AlbumLimit, int ArtistLimit) GetExternalSearchLimits(
string[]? requestedTypes,
int limit,
bool includePlaylistsAsAlbums)
{
if (limit <= 0)
{
return (0, 0, 0);
}
if (requestedTypes == null || requestedTypes.Length == 0)
{
return (limit, limit, limit);
}
var includeSongs = requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
var includeAlbums = requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase) ||
(includePlaylistsAsAlbums && requestedTypes.Contains("Playlist", StringComparer.OrdinalIgnoreCase));
var includeArtists = requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
return (
includeSongs ? limit : 0,
includeAlbums ? limit : 0,
includeArtists ? limit : 0);
}
private static IActionResult CreateEmptyItemsResponse(int startIndex)
{
return new JsonResult(new
return CreateItemsResponse([], 0, startIndex);
}
private static ContentResult CreateItemsResponse(
List<Dictionary<string, object?>> items,
int totalRecordCount,
int startIndex)
{
Items = Array.Empty<object>(),
TotalRecordCount = 0,
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(
@@ -906,82 +1058,45 @@ public partial class JellyfinController
return int.TryParse(value.ToString(), out var parsed) ? parsed : null;
}
/// <summary>
/// Score-sorts each source and then interleaves by highest remaining score.
/// This avoids weak head results in one source blocking stronger results later in that same source.
/// Merges two source queues without reordering either queue.
/// At each step, compare only the current head from each source and dequeue the winner.
/// </summary>
private List<Dictionary<string, object?>> InterleaveByScore(
List<Dictionary<string, object?>> primaryItems,
List<Dictionary<string, object?>> secondaryItems,
string query,
double primaryBoost,
double boostMinScore = 70)
double primaryBoost)
{
var primaryScored = primaryItems.Select((item, index) =>
var primaryScored = primaryItems.Select(item =>
{
var baseScore = CalculateItemRelevanceScore(query, item);
var finalScore = baseScore >= boostMinScore
? Math.Min(100.0, baseScore + primaryBoost)
: baseScore;
return new
{
Item = item,
BaseScore = baseScore,
Score = finalScore,
SourceIndex = index
Score = Math.Min(100.0, CalculateItemRelevanceScore(query, item) + primaryBoost)
};
})
.OrderByDescending(x => x.Score)
.ThenByDescending(x => x.BaseScore)
.ThenBy(x => x.SourceIndex)
.ToList();
var secondaryScored = secondaryItems.Select((item, index) =>
var secondaryScored = secondaryItems.Select(item =>
{
var baseScore = CalculateItemRelevanceScore(query, item);
return new
{
Item = item,
BaseScore = baseScore,
Score = baseScore,
SourceIndex = index
Score = CalculateItemRelevanceScore(query, item)
};
})
.OrderByDescending(x => x.Score)
.ThenByDescending(x => x.BaseScore)
.ThenBy(x => x.SourceIndex)
.ToList();
var result = new List<Dictionary<string, object?>>(primaryScored.Count + secondaryScored.Count);
int primaryIdx = 0, secondaryIdx = 0;
while (primaryIdx < primaryScored.Count || secondaryIdx < secondaryScored.Count)
while (primaryIdx < primaryScored.Count && secondaryIdx < secondaryScored.Count)
{
if (primaryIdx >= primaryScored.Count)
{
result.Add(secondaryScored[secondaryIdx++].Item);
continue;
}
if (secondaryIdx >= secondaryScored.Count)
{
result.Add(primaryScored[primaryIdx++].Item);
continue;
}
var primaryCandidate = primaryScored[primaryIdx];
var secondaryCandidate = secondaryScored[secondaryIdx];
if (primaryCandidate.Score > secondaryCandidate.Score)
{
result.Add(primaryScored[primaryIdx++].Item);
}
else if (secondaryCandidate.Score > primaryCandidate.Score)
{
result.Add(secondaryScored[secondaryIdx++].Item);
}
else if (primaryCandidate.BaseScore >= secondaryCandidate.BaseScore)
if (primaryCandidate.Score >= secondaryCandidate.Score)
{
result.Add(primaryScored[primaryIdx++].Item);
}
@@ -991,146 +1106,31 @@ public partial class JellyfinController
}
}
while (primaryIdx < primaryScored.Count)
{
result.Add(primaryScored[primaryIdx++].Item);
}
while (secondaryIdx < secondaryScored.Count)
{
result.Add(secondaryScored[secondaryIdx++].Item);
}
return result;
}
/// <summary>
/// Calculates query relevance for a search item.
/// Title is primary; metadata context is secondary and down-weighted.
/// Calculates query relevance using the product's per-type rules.
/// </summary>
private double CalculateItemRelevanceScore(string query, Dictionary<string, object?> item)
{
var title = GetItemName(item);
if (string.IsNullOrWhiteSpace(title))
return GetItemType(item) switch
{
return 0;
}
var titleScore = FuzzyMatcher.CalculateSimilarityAggressive(query, title);
var searchText = BuildItemSearchText(item, title);
if (string.Equals(searchText, title, StringComparison.OrdinalIgnoreCase))
{
return titleScore;
}
var metadataScore = FuzzyMatcher.CalculateSimilarityAggressive(query, searchText);
var weightedMetadataScore = metadataScore * 0.85;
var baseScore = Math.Max(titleScore, weightedMetadataScore);
return ApplyQueryCoverageAdjustment(query, title, searchText, baseScore);
}
private static double ApplyQueryCoverageAdjustment(string query, string title, string searchText, double baseScore)
{
var queryTokens = TokenizeForCoverage(query);
if (queryTokens.Count < 2)
{
return baseScore;
}
var titleCoverage = CalculateTokenCoverage(queryTokens, title);
var searchCoverage = string.Equals(searchText, title, StringComparison.OrdinalIgnoreCase)
? titleCoverage
: CalculateTokenCoverage(queryTokens, searchText);
var coverage = Math.Max(titleCoverage, searchCoverage);
if (coverage >= 0.999)
{
return Math.Min(100.0, baseScore + 3.0);
}
if (coverage >= 0.8)
{
return baseScore * 0.9;
}
if (coverage >= 0.6)
{
return baseScore * 0.72;
}
return baseScore * 0.5;
}
private static double CalculateTokenCoverage(IReadOnlyList<string> queryTokens, string target)
{
var targetTokens = TokenizeForCoverage(target);
if (queryTokens.Count == 0 || targetTokens.Count == 0)
{
return 0;
}
var matched = 0;
foreach (var queryToken in queryTokens)
{
if (targetTokens.Any(targetToken => IsTokenMatch(queryToken, targetToken)))
{
matched++;
}
}
return (double)matched / queryTokens.Count;
}
private static bool IsTokenMatch(string queryToken, string targetToken)
{
return queryToken.Equals(targetToken, StringComparison.Ordinal) ||
queryToken.StartsWith(targetToken, StringComparison.Ordinal) ||
targetToken.StartsWith(queryToken, StringComparison.Ordinal);
}
private static IReadOnlyList<string> TokenizeForCoverage(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
return Array.Empty<string>();
}
var normalized = NormalizeForCoverage(text);
var allTokens = normalized
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Distinct(StringComparer.Ordinal)
.ToList();
if (allTokens.Count == 0)
{
return Array.Empty<string>();
}
var significant = allTokens
.Where(token => token.Length >= 2 && !SearchStopWords.Contains(token))
.ToList();
return significant.Count > 0
? significant
: allTokens.Where(token => token.Length >= 2).ToList();
}
private static string NormalizeForCoverage(string text)
{
var normalized = RemoveDiacritics(text).ToLowerInvariant();
normalized = normalized.Replace('&', ' ');
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"[^\w\s]", " ");
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ").Trim();
return normalized;
}
private static string RemoveDiacritics(string text)
{
var normalized = text.Normalize(NormalizationForm.FormD);
var chars = new List<char>(normalized.Length);
foreach (var c in normalized)
{
if (System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c) != System.Globalization.UnicodeCategory.NonSpacingMark)
{
chars.Add(c);
}
}
return new string(chars.ToArray()).Normalize(NormalizationForm.FormC);
"Audio" => CalculateSongRelevanceScore(query, item),
"MusicAlbum" => CalculateAlbumRelevanceScore(query, item),
"MusicArtist" => CalculateArtistRelevanceScore(query, item),
_ => CalculateArtistRelevanceScore(query, item)
};
}
/// <summary>
@@ -1141,52 +1141,90 @@ public partial class JellyfinController
return GetItemStringValue(item, "Name");
}
private string BuildItemSearchText(Dictionary<string, object?> item, string title)
private double CalculateSongRelevanceScore(string query, Dictionary<string, object?> item)
{
var parts = new List<string>();
AddDistinct(parts, title);
AddDistinct(parts, GetItemStringValue(item, "SortName"));
AddDistinct(parts, GetItemStringValue(item, "AlbumArtist"));
AddDistinct(parts, GetItemStringValue(item, "Artist"));
AddDistinct(parts, GetItemStringValue(item, "Album"));
foreach (var artist in GetItemStringList(item, "Artists").Take(3))
{
AddDistinct(parts, artist);
var title = GetItemName(item);
var artistText = GetSongArtistText(item);
return CalculateBestFuzzyScore(query, title, CombineSearchFields(title, artistText));
}
return string.Join(" ", parts);
private double CalculateAlbumRelevanceScore(string query, Dictionary<string, object?> item)
{
var albumName = GetItemName(item);
var artistText = GetAlbumArtistText(item);
return CalculateBestFuzzyScore(query, albumName, CombineSearchFields(albumName, artistText));
}
private static readonly HashSet<string> SearchStopWords = new(StringComparer.Ordinal)
private double CalculateArtistRelevanceScore(string query, Dictionary<string, object?> item)
{
"a",
"an",
"and",
"at",
"for",
"in",
"of",
"on",
"the",
"to",
"with",
"feat",
"ft"
};
private static void AddDistinct(List<string> values, string? value)
var artistName = GetItemName(item);
if (string.IsNullOrWhiteSpace(artistName))
{
if (string.IsNullOrWhiteSpace(value))
{
return;
return 0;
}
if (!values.Contains(value, StringComparer.OrdinalIgnoreCase))
{
values.Add(value);
return FuzzyMatcher.CalculateSimilarityAggressive(query, artistName);
}
private double CalculateBestFuzzyScore(string query, params string?[] candidates)
{
var best = 0;
foreach (var candidate in candidates)
{
if (string.IsNullOrWhiteSpace(candidate))
{
continue;
}
best = Math.Max(best, FuzzyMatcher.CalculateSimilarityAggressive(query, candidate));
}
return best;
}
private static string CombineSearchFields(params string?[] fields)
{
return string.Join(" ", fields.Where(field => !string.IsNullOrWhiteSpace(field)));
}
private string GetItemType(Dictionary<string, object?> item)
{
return GetItemStringValue(item, "Type");
}
private string GetSongArtistText(Dictionary<string, object?> item)
{
var artists = GetItemStringList(item, "Artists").Take(3).ToList();
if (artists.Count > 0)
{
return string.Join(" ", artists);
}
var albumArtist = GetItemStringValue(item, "AlbumArtist");
if (!string.IsNullOrWhiteSpace(albumArtist))
{
return albumArtist;
}
return GetItemStringValue(item, "Artist");
}
private string GetAlbumArtistText(Dictionary<string, object?> item)
{
var albumArtist = GetItemStringValue(item, "AlbumArtist");
if (!string.IsNullOrWhiteSpace(albumArtist))
{
return albumArtist;
}
var artists = GetItemStringList(item, "Artists").Take(3).ToList();
if (artists.Count > 0)
{
return string.Join(" ", artists);
}
return GetItemStringValue(item, "Artist");
}
private string GetItemStringValue(Dictionary<string, object?> item, string key)
@@ -57,8 +57,18 @@ public partial class JellyfinController
private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName,
string playlistId)
{
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
var playlistConfig = SpotifyPlaylistScopeResolver.ResolveConfig(
_spotifySettings,
spotifyPlaylistName,
userId,
playlistId);
var playlistScopeUserId = SpotifyPlaylistScopeResolver.GetUserId(playlistConfig, userId);
var playlistScopeId = SpotifyPlaylistScopeResolver.GetScopeId(playlistConfig, playlistId);
// Check if Jellyfin playlist has changed (cheap API call)
var jellyfinSignatureCacheKey = $"spotify:playlist:jellyfin-signature:{spotifyPlaylistName}";
var jellyfinSignatureCacheKey =
$"spotify:playlist:jellyfin-signature:{CacheKeyBuilder.BuildSpotifyPlaylistScope(spotifyPlaylistName, playlistScopeUserId, playlistScopeId)}";
var currentJellyfinSignature = await GetJellyfinPlaylistSignatureAsync(playlistId);
var cachedJellyfinSignature = await _cache.GetAsync<string>(jellyfinSignatureCacheKey);
@@ -66,7 +76,10 @@ public partial class JellyfinController
var requestNeedsGenreMetadata = RequestIncludesField("Genres");
// Check Redis cache first for fast serving (only if Jellyfin playlist hasn't changed)
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(spotifyPlaylistName);
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
spotifyPlaylistName,
playlistScopeUserId,
playlistScopeId);
var cachedItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(cacheKey);
if (cachedItems != null && cachedItems.Count > 0 &&
@@ -110,7 +123,7 @@ public partial class JellyfinController
}
// Check file cache as fallback
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName);
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName, playlistScopeUserId, playlistScopeId);
if (fileItems != null && fileItems.Count > 0 &&
InjectedPlaylistItemHelper.ContainsSyntheticLocalItems(fileItems))
{
@@ -147,26 +160,74 @@ public partial class JellyfinController
}
// Check for ordered matched tracks from SpotifyTrackMatchingService
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(spotifyPlaylistName);
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
spotifyPlaylistName,
playlistScopeUserId,
playlistScopeId);
var orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
if (orderedTracks == null || orderedTracks.Count == 0)
{
_logger.LogInformation("No ordered matched tracks in cache for {Playlist}, checking if we can fetch",
_logger.LogInformation(
"No ordered matched tracks in cache for {Playlist}; attempting exact-scope rebuild before fallback",
spotifyPlaylistName);
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}",
orderedTracks.Count, spotifyPlaylistName);
// Get existing Jellyfin playlist items (RAW - don't convert!)
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
var userId = _settings.UserId;
if (string.IsNullOrEmpty(userId))
{
_logger.LogError(
"❌ JELLYFIN_USER_ID is NOT configured! Cannot fetch playlist tracks. Set it in .env or admin UI.");
"❌ Could not resolve Jellyfin user from the current request. Cannot fetch playlist tracks.");
return null; // Fall back to legacy mode
}
@@ -237,7 +298,7 @@ public partial class JellyfinController
}
// Get the full playlist from Spotify to know the correct order
var spotifyTracks = await _spotifyPlaylistFetcher!.GetPlaylistTracksAsync(spotifyPlaylistName);
var spotifyTracks = await _spotifyPlaylistFetcher!.GetPlaylistTracksAsync(spotifyPlaylistName, userId, playlistId);
if (spotifyTracks.Count == 0)
{
_logger.LogWarning("Could not get Spotify playlist tracks for {Playlist}", spotifyPlaylistName);
@@ -394,7 +455,7 @@ public partial class JellyfinController
}
// Save to file cache for persistence across restarts
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems);
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems, playlistScopeUserId, playlistScopeId);
// Also cache in Redis for fast serving (reuse the same cache key from top of method)
await _cache.SetAsync(cacheKey, finalItems, CacheExtensions.SpotifyPlaylistItemsTTL);
@@ -916,7 +977,7 @@ public partial class JellyfinController
{
try
{
var userId = _settings.UserId;
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
var playlistItemsUrl = $"Playlists/{playlistId}/Items?Fields=Id";
if (!string.IsNullOrEmpty(userId))
{
@@ -958,14 +1019,19 @@ public partial class JellyfinController
/// <summary>
/// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts.
/// </summary>
private async Task SavePlaylistItemsToFile(string playlistName, List<Dictionary<string, object?>> items)
private async Task SavePlaylistItemsToFile(
string playlistName,
List<Dictionary<string, object?>> items,
string? userId = null,
string? scopeId = null)
{
try
{
var cacheDir = "/app/cache/spotify";
Directory.CreateDirectory(cacheDir);
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var safeName = AdminHelperService.SanitizeFileName(
CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, userId, scopeId));
var filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
@@ -983,11 +1049,15 @@ public partial class JellyfinController
/// <summary>
/// Loads playlist items (raw Jellyfin JSON) from file cache.
/// </summary>
private async Task<List<Dictionary<string, object?>>?> LoadPlaylistItemsFromFile(string playlistName)
private async Task<List<Dictionary<string, object?>>?> LoadPlaylistItemsFromFile(
string playlistName,
string? userId = null,
string? scopeId = null)
{
try
{
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var safeName = AdminHelperService.SanitizeFileName(
CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, userId, scopeId));
var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_items.json");
if (!System.IO.File.Exists(filePath))
+161 -22
View File
@@ -34,15 +34,18 @@ public partial class JellyfinController : ControllerBase
private readonly SpotifyApiSettings _spotifyApiSettings;
private readonly ScrobblingSettings _scrobblingSettings;
private readonly IMusicMetadataService _metadataService;
private readonly ExternalArtistAppearancesService _externalArtistAppearancesService;
private readonly ParallelMetadataService? _parallelMetadataService;
private readonly ILocalLibraryService _localLibraryService;
private readonly IDownloadService _downloadService;
private readonly JellyfinResponseBuilder _responseBuilder;
private readonly JellyfinModelMapper _modelMapper;
private readonly JellyfinProxyService _proxyService;
private readonly JellyfinUserContextResolver _jellyfinUserContextResolver;
private readonly JellyfinSessionManager _sessionManager;
private readonly PlaylistSyncService? _playlistSyncService;
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
private readonly SpotifyTrackMatchingService? _spotifyTrackMatchingService;
private readonly SpotifyLyricsService? _spotifyLyricsService;
private readonly LyricsPlusService? _lyricsPlusService;
private readonly LrclibService? _lrclibService;
@@ -60,11 +63,13 @@ public partial class JellyfinController : ControllerBase
IOptions<SpotifyApiSettings> spotifyApiSettings,
IOptions<ScrobblingSettings> scrobblingSettings,
IMusicMetadataService metadataService,
ExternalArtistAppearancesService externalArtistAppearancesService,
ILocalLibraryService localLibraryService,
IDownloadService downloadService,
JellyfinResponseBuilder responseBuilder,
JellyfinModelMapper modelMapper,
JellyfinProxyService proxyService,
JellyfinUserContextResolver jellyfinUserContextResolver,
JellyfinSessionManager sessionManager,
OdesliService odesliService,
RedisCacheService cache,
@@ -73,6 +78,7 @@ public partial class JellyfinController : ControllerBase
ParallelMetadataService? parallelMetadataService = null,
PlaylistSyncService? playlistSyncService = null,
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
SpotifyTrackMatchingService? spotifyTrackMatchingService = null,
SpotifyLyricsService? spotifyLyricsService = null,
LyricsPlusService? lyricsPlusService = null,
LrclibService? lrclibService = null,
@@ -85,15 +91,18 @@ public partial class JellyfinController : ControllerBase
_spotifyApiSettings = spotifyApiSettings.Value;
_scrobblingSettings = scrobblingSettings.Value;
_metadataService = metadataService;
_externalArtistAppearancesService = externalArtistAppearancesService;
_parallelMetadataService = parallelMetadataService;
_localLibraryService = localLibraryService;
_downloadService = downloadService;
_responseBuilder = responseBuilder;
_modelMapper = modelMapper;
_proxyService = proxyService;
_jellyfinUserContextResolver = jellyfinUserContextResolver;
_sessionManager = sessionManager;
_playlistSyncService = playlistSyncService;
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
_spotifyTrackMatchingService = spotifyTrackMatchingService;
_spotifyLyricsService = spotifyLyricsService;
_lyricsPlusService = lyricsPlusService;
_lrclibService = lrclibService;
@@ -290,6 +299,75 @@ public partial class JellyfinController : ControllerBase
return _responseBuilder.CreateItemsResponse(new List<Song>());
}
/// <summary>
/// Gets "appears on" albums for an external artist when Jellyfin requests
/// ContributingArtistIds for album containers.
/// </summary>
private async Task<IActionResult> GetExternalContributorChildItems(string provider, string type, string externalId, string? includeItemTypes, CancellationToken cancellationToken = default)
{
if (IsFavoritesOnlyRequest())
{
_logger.LogDebug(
"Suppressing external contributing artist items for favorites-only request: provider={Provider}, type={Type}, externalId={ExternalId}",
provider,
type,
externalId);
return CreateEmptyItemsResponse(GetRequestedStartIndex());
}
if (!string.Equals(type, "artist", StringComparison.OrdinalIgnoreCase))
{
_logger.LogDebug(
"Ignoring external contributing item request for non-artist id: provider={Provider}, type={Type}, externalId={ExternalId}",
provider,
type,
externalId);
return CreateEmptyItemsResponse(GetRequestedStartIndex());
}
var itemTypes = ParseItemTypes(includeItemTypes);
var itemTypesUnspecified = itemTypes == null || itemTypes.Length == 0;
var wantsAlbums = itemTypesUnspecified || itemTypes!.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase);
if (!wantsAlbums)
{
_logger.LogDebug(
"No external contributing artist handler for requested item types {ItemTypes}",
string.Join(",", itemTypes ?? Array.Empty<string>()));
return CreateEmptyItemsResponse(GetRequestedStartIndex());
}
var albums = await _externalArtistAppearancesService.GetAppearsOnAlbumsAsync(provider, externalId, cancellationToken);
var items = albums
.Select(_responseBuilder.ConvertAlbumToJellyfinItem)
.ToList();
items = ApplyRequestedAlbumOrderingIfApplicable(
items,
itemTypes,
Request.Query["SortBy"].ToString(),
Request.Query["SortOrder"].ToString());
var totalRecordCount = items.Count;
var startIndex = GetRequestedStartIndex();
if (startIndex > 0)
{
items = items.Skip(startIndex).ToList();
}
if (int.TryParse(Request.Query["Limit"], out var parsedLimit) && parsedLimit > 0)
{
items = items.Take(parsedLimit).ToList();
}
return _responseBuilder.CreateJsonResponse(new
{
Items = items,
TotalRecordCount = totalRecordCount,
StartIndex = startIndex
});
}
private int GetRequestedStartIndex()
{
return int.TryParse(Request.Query["StartIndex"], out var startIndex) && startIndex > 0
@@ -628,13 +706,20 @@ public partial class JellyfinController : ControllerBase
if (!isExternal)
{
var effectiveImageTag = tag;
if (string.IsNullOrWhiteSpace(effectiveImageTag) &&
_spotifySettings.IsSpotifyPlaylist(itemId))
{
effectiveImageTag = await ResolveCurrentSpotifyPlaylistImageTagAsync(itemId, imageType);
}
// Proxy image from Jellyfin for local content
var (imageBytes, contentType) = await _proxyService.GetImageAsync(
itemId,
imageType,
maxWidth,
maxHeight,
tag);
effectiveImageTag);
if (imageBytes == null || contentType == null)
{
@@ -671,7 +756,7 @@ public partial class JellyfinController : ControllerBase
if (fallbackBytes != null && fallbackContentType != null)
{
return File(fallbackBytes, fallbackContentType);
return CreateConditionalImageResponse(fallbackBytes, fallbackContentType);
}
}
}
@@ -680,7 +765,7 @@ public partial class JellyfinController : ControllerBase
return await GetPlaceholderImageAsync();
}
return File(imageBytes, contentType);
return CreateConditionalImageResponse(imageBytes, contentType);
}
// Check Redis cache for previously fetched external image
@@ -689,7 +774,7 @@ public partial class JellyfinController : ControllerBase
if (cachedImageBytes != null)
{
_logger.LogDebug("Cache hit for external {Type} image: {Provider}/{ExternalId}", type, provider, externalId);
return File(cachedImageBytes, "image/jpeg");
return CreateConditionalImageResponse(cachedImageBytes, "image/jpeg");
}
// Get external cover art URL
@@ -760,7 +845,7 @@ public partial class JellyfinController : ControllerBase
_logger.LogDebug("Successfully fetched and cached external image from host {Host}, size: {Size} bytes",
safeCoverUri.Host, imageBytes.Length);
return File(imageBytes, "image/jpeg");
return CreateConditionalImageResponse(imageBytes, "image/jpeg");
}
catch (Exception ex)
{
@@ -782,7 +867,7 @@ public partial class JellyfinController : ControllerBase
if (System.IO.File.Exists(placeholderPath))
{
var imageBytes = await System.IO.File.ReadAllBytesAsync(placeholderPath);
return File(imageBytes, "image/png");
return CreateConditionalImageResponse(imageBytes, "image/png");
}
// Fallback: Return a 1x1 transparent PNG as minimal placeholder
@@ -790,7 +875,54 @@ public partial class JellyfinController : ControllerBase
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
);
return File(transparentPng, "image/png");
return CreateConditionalImageResponse(transparentPng, "image/png");
}
private IActionResult CreateConditionalImageResponse(byte[] imageBytes, string contentType)
{
var etag = ImageConditionalRequestHelper.ComputeStrongETag(imageBytes);
Response.Headers["ETag"] = etag;
if (ImageConditionalRequestHelper.MatchesIfNoneMatch(Request.Headers, etag))
{
return StatusCode(StatusCodes.Status304NotModified);
}
return File(imageBytes, contentType);
}
private async Task<string?> ResolveCurrentSpotifyPlaylistImageTagAsync(string itemId, string imageType)
{
try
{
var (itemResult, statusCode) = await _proxyService.GetJsonAsyncInternal($"Items/{itemId}");
if (itemResult == null || statusCode != 200)
{
return null;
}
using var itemDocument = itemResult;
var imageTag = ExtractImageTag(itemDocument.RootElement, imageType);
if (!string.IsNullOrWhiteSpace(imageTag))
{
_logger.LogDebug(
"Resolved current Jellyfin {ImageType} image tag for Spotify playlist {PlaylistId}: {ImageTag}",
imageType,
itemId,
imageTag);
}
return imageTag;
}
catch (Exception ex)
{
_logger.LogDebug(ex,
"Failed to resolve current Jellyfin {ImageType} image tag for Spotify playlist {PlaylistId}",
imageType,
itemId);
return null;
}
}
#endregion
@@ -1292,33 +1424,37 @@ public partial class JellyfinController : ControllerBase
});
}
// Intercept Spotify playlist requests by ID
if (_spotifySettings.Enabled &&
path.StartsWith("playlists/", StringComparison.OrdinalIgnoreCase) &&
path.Contains("/items", StringComparison.OrdinalIgnoreCase))
var playlistItemsRequestId = GetExactPlaylistItemsRequestId(path);
if (!string.IsNullOrEmpty(playlistItemsRequestId))
{
// Extract playlist ID from path: playlists/{id}/items
var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2 && parts[0].Equals("playlists", StringComparison.OrdinalIgnoreCase))
if (_spotifySettings.Enabled)
{
var playlistId = parts[1];
_logger.LogDebug("=== PLAYLIST REQUEST ===");
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistItemsRequestId);
_logger.LogInformation("Spotify Enabled: {Enabled}", _spotifySettings.Enabled);
_logger.LogInformation("Configured Playlists: {Playlists}", string.Join(", ", _spotifySettings.Playlists.Select(p => $"{p.Name}:{p.Id}")));
_logger.LogInformation("Is configured: {IsConfigured}", _spotifySettings.IsSpotifyPlaylist(playlistId));
_logger.LogInformation("Is configured: {IsConfigured}", _spotifySettings.IsSpotifyPlaylist(playlistItemsRequestId));
// Check if this playlist ID is configured for Spotify injection
if (_spotifySettings.IsSpotifyPlaylist(playlistId))
if (_spotifySettings.IsSpotifyPlaylist(playlistItemsRequestId))
{
_logger.LogInformation("========================================");
_logger.LogInformation("=== INTERCEPTING SPOTIFY PLAYLIST ===");
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistItemsRequestId);
_logger.LogInformation("========================================");
return await GetPlaylistTracks(playlistId);
return await GetPlaylistTracks(playlistItemsRequestId);
}
}
var playlistItemsPath = path;
if (Request.QueryString.HasValue)
{
playlistItemsPath = $"{playlistItemsPath}{Request.QueryString.Value}";
}
_logger.LogDebug("Using transparent Jellyfin passthrough for non-injected playlist {PlaylistId}",
playlistItemsRequestId);
return await ProxyJsonPassthroughAsync(playlistItemsPath);
}
// Handle non-JSON responses (images, robots.txt, etc.)
@@ -1690,7 +1826,10 @@ public partial class JellyfinController : ControllerBase
// Search through each playlist's matched tracks cache
foreach (var playlist in playlists)
{
var cacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name);
var cacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
playlist.Name,
playlist.UserId,
string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId);
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(cacheKey);
if (matchedTracks == null || matchedTracks.Count == 0)
+123 -27
View File
@@ -8,6 +8,7 @@ using allstarr.Services.Common;
using allstarr.Services.Admin;
using allstarr.Services;
using allstarr.Filters;
using allstarr.Services.Jellyfin;
using System.Text.Json;
namespace allstarr.Controllers;
@@ -27,6 +28,7 @@ public class PlaylistController : ControllerBase
private readonly HttpClient _jellyfinHttpClient;
private readonly AdminHelperService _helperService;
private readonly IServiceProvider _serviceProvider;
private readonly JellyfinUserContextResolver _jellyfinUserContextResolver;
private const string CacheDirectory = "/app/cache/spotify";
public PlaylistController(
@@ -39,6 +41,7 @@ public class PlaylistController : ControllerBase
IHttpClientFactory httpClientFactory,
AdminHelperService helperService,
IServiceProvider serviceProvider,
JellyfinUserContextResolver jellyfinUserContextResolver,
SpotifyTrackMatchingService? matchingService = null)
{
_logger = logger;
@@ -51,6 +54,23 @@ public class PlaylistController : ControllerBase
_jellyfinHttpClient = httpClientFactory.CreateClient();
_helperService = helperService;
_serviceProvider = serviceProvider;
_jellyfinUserContextResolver = jellyfinUserContextResolver;
}
private async Task<SpotifyPlaylistConfig?> ResolvePlaylistConfigForCurrentScopeAsync(string playlistName)
{
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync();
return _spotifyImportSettings.GetPlaylistByName(playlistName, userId);
}
private static string? GetPlaylistScopeId(SpotifyPlaylistConfig? playlist)
{
if (!string.IsNullOrWhiteSpace(playlist?.JellyfinId))
{
return playlist.JellyfinId;
}
return string.IsNullOrWhiteSpace(playlist?.Id) ? null : playlist.Id;
}
[HttpGet("playlists")]
@@ -149,7 +169,7 @@ public class PlaylistController : ControllerBase
{
try
{
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
spotifyTrackCount = spotifyTracks.Count;
playlistInfo["trackCount"] = spotifyTrackCount;
_logger.LogDebug("Fetched {Count} tracks from Spotify for playlist {Name}", spotifyTrackCount, config.Name);
@@ -167,7 +187,10 @@ public class PlaylistController : ControllerBase
try
{
// Try to use the pre-built playlist cache
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(config.Name);
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
config.Name,
config.UserId,
GetPlaylistScopeId(config));
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
try
@@ -239,7 +262,7 @@ public class PlaylistController : ControllerBase
else
{
// No playlist cache - calculate from global mappings as fallback
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
var localCount = 0;
var externalCount = 0;
var missingCount = 0;
@@ -291,7 +314,7 @@ public class PlaylistController : ControllerBase
try
{
// Jellyfin requires UserId parameter to fetch playlist items
var userId = _jellyfinSettings.UserId;
var userId = config.UserId;
// If no user configured, try to get the first user
if (string.IsNullOrEmpty(userId))
@@ -330,10 +353,13 @@ public class PlaylistController : ControllerBase
if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items))
{
// Get Spotify tracks to match against
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
// Try to use the pre-built playlist cache first (includes manual mappings!)
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(config.Name);
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
config.Name,
config.UserId,
GetPlaylistScopeId(config));
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
try
@@ -438,7 +464,10 @@ public class PlaylistController : ControllerBase
}
// Get matched external tracks cache once
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(config.Name);
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
config.Name,
config.UserId,
GetPlaylistScopeId(config));
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
var matchedSpotifyIds = new HashSet<string>(
matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
@@ -455,7 +484,11 @@ public class PlaylistController : ControllerBase
var hasExternalMapping = false;
// FIRST: Check for manual Jellyfin mapping
var manualMappingKey = $"spotify:manual-map:{config.Name}:{track.SpotifyId}";
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
config.Name,
track.SpotifyId,
config.UserId,
GetPlaylistScopeId(config));
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId))
@@ -466,7 +499,11 @@ public class PlaylistController : ControllerBase
else
{
// Check for external manual mapping
var externalMappingKey = $"spotify:external-map:{config.Name}:{track.SpotifyId}";
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
config.Name,
track.SpotifyId,
config.UserId,
GetPlaylistScopeId(config));
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
if (!string.IsNullOrEmpty(externalMappingJson))
@@ -592,16 +629,22 @@ public class PlaylistController : ControllerBase
public async Task<IActionResult> GetPlaylistTracks(string name)
{
var decodedName = Uri.UnescapeDataString(name);
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
var playlistScopeUserId = playlistConfig?.UserId;
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
// Get Spotify tracks
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName);
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName, playlistScopeUserId, playlistConfig?.JellyfinId);
var tracksWithStatus = new List<object>();
var matchedTracksBySpotifyId = new Dictionary<string, MatchedTrack>(StringComparer.OrdinalIgnoreCase);
try
{
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName);
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
if (matchedTracks != null)
@@ -627,7 +670,10 @@ public class PlaylistController : ControllerBase
// Use the pre-built playlist cache (same as GetPlaylists endpoint)
// This cache includes all matched tracks with proper provider IDs
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName);
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
try
@@ -948,7 +994,11 @@ public class PlaylistController : ControllerBase
string? externalProvider = null;
// Check for manual Jellyfin mapping
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
decodedName,
track.SpotifyId,
playlistScopeUserId,
playlistScopeId);
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId))
@@ -958,7 +1008,11 @@ public class PlaylistController : ControllerBase
else
{
// Check for external manual mapping
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
decodedName,
track.SpotifyId,
playlistScopeUserId,
playlistScopeId);
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
if (!string.IsNullOrEmpty(externalMappingJson))
@@ -1071,10 +1125,16 @@ public class PlaylistController : ControllerBase
try
{
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
var playlistScopeUserId = playlistConfig?.UserId;
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
await _playlistFetcher.RefreshPlaylistAsync(decodedName);
// Clear playlist stats cache first (so it gets recalculated with fresh data)
var statsCacheKey = $"spotify:playlist:stats:{decodedName}";
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
await _cache.DeleteAsync(statsCacheKey);
// Then invalidate playlist summary cache (will rebuild with fresh stats)
@@ -1109,18 +1169,28 @@ public class PlaylistController : ControllerBase
try
{
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
var playlistScopeUserId = playlistConfig?.UserId;
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
// Clear the Jellyfin playlist signature cache to force re-checking if local tracks changed
var jellyfinSignatureCacheKey = $"spotify:playlist:jellyfin-signature:{decodedName}";
var jellyfinSignatureCacheKey =
$"spotify:playlist:jellyfin-signature:{CacheKeyBuilder.BuildSpotifyPlaylistScope(decodedName, playlistScopeUserId, playlistScopeId)}";
await _cache.DeleteAsync(jellyfinSignatureCacheKey);
_logger.LogDebug("Cleared Jellyfin signature cache to force change detection");
// Clear the matched results cache to force re-matching
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName);
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
await _cache.DeleteAsync(matchedTracksKey);
_logger.LogDebug("Cleared matched tracks cache");
// Clear the playlist items cache
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName);
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
await _cache.DeleteAsync(playlistItemsCacheKey);
_logger.LogDebug("Cleared playlist items cache");
@@ -1131,7 +1201,10 @@ public class PlaylistController : ControllerBase
_helperService.InvalidatePlaylistSummaryCache();
// Clear playlist stats cache to force recalculation from new mappings
var statsCacheKey = $"spotify:playlist:stats:{decodedName}";
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
await _cache.DeleteAsync(statsCacheKey);
_logger.LogDebug("Cleared stats cache for {Name}", decodedName);
@@ -1196,7 +1269,7 @@ public class PlaylistController : ControllerBase
try
{
var userId = _jellyfinSettings.UserId;
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync();
// Build URL with UserId if available
var url = $"{_jellyfinSettings.Url}/Items?searchTerm={Uri.EscapeDataString(query)}&includeItemTypes=Audio&recursive=true&limit=20";
@@ -1328,7 +1401,7 @@ public class PlaylistController : ControllerBase
try
{
var userId = _jellyfinSettings.UserId;
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync();
var url = $"{_jellyfinSettings.Url}/Items/{id}";
if (!string.IsNullOrEmpty(userId))
@@ -1424,13 +1497,20 @@ public class PlaylistController : ControllerBase
try
{
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
var playlistScopeUserId = playlistConfig?.UserId;
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
string? normalizedProvider = null;
string? normalizedExternalId = null;
if (hasJellyfinMapping)
{
// Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent)
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
var mappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
decodedName,
request.SpotifyId,
playlistScopeUserId,
playlistScopeId);
await _cache.SetAsync(mappingKey, request.JellyfinId!);
// Also save to file for persistence across restarts
@@ -1442,7 +1522,11 @@ public class PlaylistController : ControllerBase
else
{
// Store external mapping in cache (NO EXPIRATION - manual mappings are permanent)
var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
decodedName,
request.SpotifyId,
playlistScopeUserId,
playlistScopeId);
normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
normalizedExternalId = NormalizeExternalTrackId(normalizedProvider, request.ExternalId!);
var externalMapping = new { provider = normalizedProvider, id = normalizedExternalId };
@@ -1482,10 +1566,22 @@ public class PlaylistController : ControllerBase
}
// Clear all related caches to force rebuild
var matchedCacheKey = $"spotify:matched:{decodedName}";
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName);
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName);
var statsCacheKey = $"spotify:playlist:stats:{decodedName}";
var matchedCacheKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
await _cache.DeleteAsync(matchedCacheKey);
await _cache.DeleteAsync(orderedCacheKey);
@@ -357,9 +357,9 @@ public class SpotifyAdminController : ControllerBase
{
var keys = new[]
{
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name)
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId)
};
foreach (var key in keys)
@@ -152,6 +152,11 @@ public class WebSocketProxyMiddleware
clientWebSocket = await context.WebSockets.AcceptWebSocketAsync();
_logger.LogDebug("✓ WEBSOCKET: Client WebSocket accepted");
if (!string.IsNullOrEmpty(deviceId))
{
await _sessionManager.RegisterProxiedWebSocketAsync(deviceId);
}
// Start bidirectional proxying
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
var serverToClient = ProxyMessagesAsync(serverWebSocket, clientWebSocket, "Server→Client", context.RequestAborted);
@@ -194,6 +199,11 @@ public class WebSocketProxyMiddleware
}
finally
{
if (!string.IsNullOrEmpty(deviceId))
{
_sessionManager.UnregisterProxiedWebSocket(deviceId);
}
// Clean up connections
if (clientWebSocket?.State == WebSocketState.Open)
{
+26 -3
View File
@@ -79,6 +79,29 @@ public class TrackMetadataRequest
public int? DurationMs { get; set; }
}
/// <summary>
/// Request model for updating configuration
/// </summary>
public class SquidWtfEndpointHealthResponse
{
public DateTime TestedAtUtc { get; set; }
public int TotalRows { get; set; }
public List<SquidWtfEndpointHealthRow> Endpoints { get; set; } = new();
}
public class SquidWtfEndpointHealthRow
{
public string Host { get; set; } = string.Empty;
public string? ApiUrl { get; set; }
public string? StreamingUrl { get; set; }
public SquidWtfEndpointProbeResult Api { get; set; } = new();
public SquidWtfEndpointProbeResult Streaming { get; set; } = new();
}
public class SquidWtfEndpointProbeResult
{
public bool Configured { get; set; }
public bool IsUp { get; set; }
public string State { get; set; } = "unknown";
public int? StatusCode { get; set; }
public long? LatencyMs { get; set; }
public string? RequestUrl { get; set; }
public string? Error { get; set; }
}
@@ -0,0 +1,37 @@
namespace allstarr.Models.Jellyfin;
/// <summary>
/// Canonical Jellyfin Items response wrapper used by search-related hot paths.
/// </summary>
public class JellyfinItemsResponse
{
public List<Dictionary<string, object?>> Items { get; set; } = [];
public int TotalRecordCount { get; set; }
public int StartIndex { get; set; }
}
/// <summary>
/// Playback payload forwarded to Jellyfin for start/progress/stop events.
/// Nullable members are omitted to preserve the lean request shapes clients expect.
/// </summary>
public class JellyfinPlaybackStatePayload
{
public string ItemId { get; set; } = string.Empty;
public long PositionTicks { get; set; }
public bool? CanSeek { get; set; }
public bool? IsPaused { get; set; }
public bool? IsMuted { get; set; }
public string? PlayMethod { get; set; }
}
/// <summary>
/// Synthetic capabilities payload used when allstarr needs to establish a Jellyfin session.
/// </summary>
public class JellyfinSessionCapabilitiesPayload
{
public string[] PlayableMediaTypes { get; set; } = [];
public string[] SupportedCommands { get; set; } = [];
public bool SupportsMediaControl { get; set; }
public bool SupportsPersistentIdentifier { get; set; }
public bool SupportsSync { get; set; }
}
@@ -126,8 +126,33 @@ public class SpotifyImportSettings
/// <summary>
/// Gets the playlist configuration by name.
/// </summary>
public SpotifyPlaylistConfig? GetPlaylistByName(string name) =>
Playlists.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
public SpotifyPlaylistConfig? GetPlaylistByName(string name, string? userId = null, string? jellyfinPlaylistId = null)
{
var matches = Playlists
.Where(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
.ToList();
if (!string.IsNullOrWhiteSpace(jellyfinPlaylistId))
{
var byPlaylistId = matches.FirstOrDefault(p =>
p.JellyfinId.Equals(jellyfinPlaylistId, StringComparison.OrdinalIgnoreCase));
if (byPlaylistId != null)
{
return byPlaylistId;
}
}
if (!string.IsNullOrWhiteSpace(userId))
{
var normalizedUserId = userId.Trim();
return matches.FirstOrDefault(p =>
!string.IsNullOrWhiteSpace(p.UserId) &&
p.UserId.Equals(normalizedUserId, StringComparison.OrdinalIgnoreCase))
?? matches.FirstOrDefault(p => string.IsNullOrWhiteSpace(p.UserId));
}
return matches.FirstOrDefault();
}
/// <summary>
/// Checks if a Jellyfin playlist ID is configured for Spotify import.
+23
View File
@@ -16,6 +16,7 @@ using Microsoft.Extensions.Http;
using System.Net;
var builder = WebApplication.CreateBuilder(args);
RuntimeEnvConfiguration.AddDotEnvOverrides(builder.Configuration, builder.Environment, Console.Out);
// Discover SquidWTF API and streaming endpoints from uptime feeds.
var squidWtfEndpointCatalog = await SquidWtfEndpointDiscovery.DiscoverAsync();
@@ -175,6 +176,25 @@ builder.Services.ConfigureAll<HttpClientFactoryOptions>(options =>
// but we want to reduce noise in production logs
options.SuppressHandlerScope = true;
});
// Register a dedicated named HttpClient for Jellyfin backend with connection pooling.
// SocketsHttpHandler reuses TCP connections across the scoped JellyfinProxyService
// instances, eliminating per-request TCP/TLS handshake overhead.
builder.Services.AddHttpClient(JellyfinProxyService.HttpClientName)
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
// Keep up to 20 idle connections to Jellyfin alive at any time
MaxConnectionsPerServer = 20,
// Recycle pooled connections every 5 minutes to pick up DNS changes
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
// Close idle connections after 90 seconds to avoid stale sockets
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90),
// Allow HTTP/2 multiplexing when Jellyfin supports it
EnableMultipleHttp2Connections = true,
// Follow redirects within Jellyfin
AllowAutoRedirect = true,
MaxAutomaticRedirections = 5
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddHttpContextAccessor();
@@ -508,6 +528,7 @@ else
// Business services - shared across backends
builder.Services.AddSingleton(squidWtfEndpointCatalog);
builder.Services.AddMemoryCache(); // L1 in-memory tier for RedisCacheService
builder.Services.AddSingleton<RedisCacheService>();
builder.Services.AddSingleton<FavoritesMigrationService>();
builder.Services.AddSingleton<OdesliService>();
@@ -520,6 +541,8 @@ if (backendType == BackendType.Jellyfin)
// Jellyfin services
builder.Services.AddSingleton<JellyfinResponseBuilder>();
builder.Services.AddSingleton<JellyfinModelMapper>();
builder.Services.AddSingleton<ExternalArtistAppearancesService>();
builder.Services.AddScoped<JellyfinUserContextResolver>();
builder.Services.AddScoped<JellyfinProxyService>();
builder.Services.AddSingleton<JellyfinSessionManager>();
builder.Services.AddScoped<JellyfinAuthFilter>();
@@ -0,0 +1,67 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using allstarr.Models.Domain;
using allstarr.Models.Jellyfin;
using allstarr.Models.Lyrics;
using allstarr.Models.Search;
using allstarr.Models.Spotify;
namespace allstarr.Serialization;
/// <summary>
/// System.Text.Json source-generated serializer context for hot-path types.
/// Eliminates runtime reflection for serialize/deserialize operations, providing
/// 3-8x faster throughput and significantly reduced GC allocations.
///
/// Used by RedisCacheService (all cached types), search response serialization,
/// and playback session payload construction.
/// </summary>
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.Unspecified,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
)]
// Domain models (hot: cached in Redis, serialized in search responses)
[JsonSerializable(typeof(Song))]
[JsonSerializable(typeof(Album))]
[JsonSerializable(typeof(Artist))]
[JsonSerializable(typeof(SearchResult))]
[JsonSerializable(typeof(JellyfinItemsResponse))]
[JsonSerializable(typeof(JellyfinPlaybackStatePayload))]
[JsonSerializable(typeof(JellyfinSessionCapabilitiesPayload))]
[JsonSerializable(typeof(List<Song>))]
[JsonSerializable(typeof(List<Album>))]
[JsonSerializable(typeof(List<Artist>))]
// Spotify models (hot: playlist loading, track matching)
[JsonSerializable(typeof(SpotifyPlaylistTrack))]
[JsonSerializable(typeof(SpotifyPlaylist))]
[JsonSerializable(typeof(MatchedTrack))]
[JsonSerializable(typeof(MissingTrack))]
[JsonSerializable(typeof(SpotifyTrackMapping))]
[JsonSerializable(typeof(TrackMetadata))]
[JsonSerializable(typeof(List<SpotifyPlaylistTrack>))]
[JsonSerializable(typeof(List<MatchedTrack>))]
[JsonSerializable(typeof(List<MissingTrack>))]
// Lyrics models (moderate: cached in Redis)
[JsonSerializable(typeof(LyricsInfo))]
// Collection types used in cache and playlist items
[JsonSerializable(typeof(List<Dictionary<string, object?>>))]
[JsonSerializable(typeof(Dictionary<string, object?>))]
[JsonSerializable(typeof(string[]))]
[JsonSerializable(typeof(List<string>))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(byte[]))]
internal partial class AllstarrJsonContext : JsonSerializerContext
{
/// <summary>
/// Shared default instance. Use this for all hot-path serialization
/// where PropertyNamingPolicy = null (PascalCase / preserve casing).
/// </summary>
public static AllstarrJsonContext Shared { get; } = new(new JsonSerializerOptions
{
PropertyNamingPolicy = null,
DictionaryKeyPolicy = null,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
});
}
@@ -0,0 +1,47 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace allstarr.Serialization;
internal static class AllstarrJsonSerializer
{
private static readonly JsonSerializerOptions ReflectionFallbackOptions = new()
{
PropertyNamingPolicy = null,
DictionaryKeyPolicy = null,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
public static string Serialize<T>(T value)
{
var typeInfo = GetTypeInfo<T>();
if (typeInfo != null)
{
try
{
return JsonSerializer.Serialize(value, typeInfo);
}
catch (NotSupportedException)
{
// Mixed Jellyfin payloads often carry runtime-only shapes such as JsonElement,
// List<object>, or dictionary arrays. Fall back to reflection for those cases.
}
}
return JsonSerializer.Serialize(value, ReflectionFallbackOptions);
}
private static JsonTypeInfo<T>? GetTypeInfo<T>()
{
try
{
return (JsonTypeInfo<T>?)AllstarrJsonContext.Shared.GetTypeInfo(typeof(T));
}
catch
{
return null;
}
}
}
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
namespace allstarr.Services.Admin;
@@ -20,9 +21,7 @@ public class AdminHelperService
{
_logger = logger;
_jellyfinSettings = jellyfinSettings.Value;
_envFilePath = environment.IsDevelopment()
? Path.Combine(environment.ContentRootPath, "..", ".env")
: "/app/.env";
_envFilePath = RuntimeEnvConfiguration.ResolveEnvFilePath(environment);
}
public string GetJellyfinAuthHeader()
+91 -17
View File
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
using System.Text.RegularExpressions;
namespace allstarr.Services.Common;
@@ -9,6 +10,10 @@ namespace allstarr.Services.Common;
/// </summary>
public static class AuthHeaderHelper
{
private static readonly Regex AuthParameterRegex = new(
@"(?<key>[A-Za-z0-9_-]+)\s*=\s*""(?<value>[^""]*)""",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
/// <summary>
/// Forwards authentication headers from HTTP request to HttpRequestMessage.
/// Handles both X-Emby-Authorization and Authorization headers.
@@ -99,17 +104,7 @@ public static class AuthHeaderHelper
/// </summary>
private static string? ExtractDeviceIdFromAuthString(string authValue)
{
var deviceIdMatch = System.Text.RegularExpressions.Regex.Match(
authValue,
@"DeviceId=""([^""]+)""",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (deviceIdMatch.Success)
{
return deviceIdMatch.Groups[1].Value;
}
return null;
return ExtractAuthParameter(authValue, "DeviceId");
}
/// <summary>
@@ -140,14 +135,93 @@ public static class AuthHeaderHelper
/// </summary>
private static string? ExtractClientNameFromAuthString(string authValue)
{
var clientMatch = System.Text.RegularExpressions.Regex.Match(
authValue,
@"Client=""([^""]+)""",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
return ExtractAuthParameter(authValue, "Client");
}
if (clientMatch.Success)
/// <summary>
/// Extracts the authenticated Jellyfin access token from request headers.
/// Supports X-Emby-Authorization, X-Emby-Token, Authorization: MediaBrowser ..., and Bearer tokens.
/// </summary>
public static string? ExtractAccessToken(IHeaderDictionary headers)
{
return clientMatch.Groups[1].Value;
if (headers.TryGetValue("X-Emby-Token", out var tokenHeader))
{
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;
+45 -19
View File
@@ -67,34 +67,52 @@ public static class CacheKeyBuilder
#region Spotify Keys
public static string BuildSpotifyPlaylistKey(string playlistName)
public static string BuildSpotifyPlaylistScope(string playlistName, string? userId = null, string? scopeId = null)
{
return $"spotify:playlist:{playlistName}";
var normalizedUserId = Normalize(userId);
var normalizedScopeId = Normalize(scopeId);
var normalizedPlaylistName = Normalize(playlistName);
if (string.IsNullOrEmpty(normalizedUserId) && string.IsNullOrEmpty(normalizedScopeId))
{
return playlistName;
}
public static string BuildSpotifyPlaylistItemsKey(string playlistName)
{
return $"spotify:playlist:items:{playlistName}";
var effectiveScopeId = string.IsNullOrEmpty(normalizedScopeId)
? normalizedPlaylistName
: normalizedScopeId;
return $"{normalizedUserId}:{effectiveScopeId}";
}
public static string BuildSpotifyPlaylistOrderedKey(string playlistName)
public static string BuildSpotifyPlaylistKey(string playlistName, string? userId = null, string? scopeId = null)
{
return $"spotify:playlist:ordered:{playlistName}";
return $"spotify:playlist:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
}
public static string BuildSpotifyMatchedTracksKey(string playlistName)
public static string BuildSpotifyPlaylistItemsKey(string playlistName, string? userId = null, string? scopeId = null)
{
return $"spotify:matched:ordered:{playlistName}";
return $"spotify:playlist:items:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
}
public static string BuildSpotifyLegacyMatchedTracksKey(string playlistName)
public static string BuildSpotifyPlaylistOrderedKey(string playlistName, string? userId = null, string? scopeId = null)
{
return $"spotify:matched:{playlistName}";
return $"spotify:playlist:ordered:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
}
public static string BuildSpotifyPlaylistStatsKey(string playlistName)
public static string BuildSpotifyMatchedTracksKey(string playlistName, string? userId = null, string? scopeId = null)
{
return $"spotify:playlist:stats:{playlistName}";
return $"spotify:matched:ordered:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
}
public static string BuildSpotifyLegacyMatchedTracksKey(string playlistName, string? userId = null, string? scopeId = null)
{
return $"spotify:matched:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
}
public static string BuildSpotifyPlaylistStatsKey(string playlistName, string? userId = null, string? scopeId = null)
{
return $"spotify:playlist:stats:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
}
public static string BuildSpotifyPlaylistStatsPattern()
@@ -102,19 +120,27 @@ public static class CacheKeyBuilder
return "spotify:playlist:stats:*";
}
public static string BuildSpotifyMissingTracksKey(string playlistName)
public static string BuildSpotifyMissingTracksKey(string playlistName, string? userId = null, string? scopeId = null)
{
return $"spotify:missing:{playlistName}";
return $"spotify:missing:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
}
public static string BuildSpotifyManualMappingKey(string playlist, string spotifyId)
public static string BuildSpotifyManualMappingKey(
string playlist,
string spotifyId,
string? userId = null,
string? scopeId = null)
{
return $"spotify:manual-map:{playlist}:{spotifyId}";
return $"spotify:manual-map:{BuildSpotifyPlaylistScope(playlist, userId, scopeId)}:{spotifyId}";
}
public static string BuildSpotifyExternalMappingKey(string playlist, string spotifyId)
public static string BuildSpotifyExternalMappingKey(
string playlist,
string spotifyId,
string? userId = null,
string? scopeId = null)
{
return $"spotify:external-map:{playlist}:{spotifyId}";
return $"spotify:external-map:{BuildSpotifyPlaylistScope(playlist, userId, scopeId)}:{spotifyId}";
}
public static string BuildSpotifyGlobalMappingKey(string spotifyId)
@@ -0,0 +1,39 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Http;
namespace allstarr.Services.Common;
public static class ImageConditionalRequestHelper
{
public static string ComputeStrongETag(byte[] payload)
{
var hash = SHA256.HashData(payload);
return $"\"{Convert.ToHexString(hash)}\"";
}
public static bool MatchesIfNoneMatch(IHeaderDictionary headers, string etag)
{
if (!headers.TryGetValue("If-None-Match", out var headerValues))
{
return false;
}
foreach (var headerValue in headerValues)
{
if (string.IsNullOrEmpty(headerValue))
{
continue;
}
foreach (var candidate in headerValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (candidate == "*" || string.Equals(candidate, etag, StringComparison.Ordinal))
{
return true;
}
}
}
return false;
}
}
+278 -26
View File
@@ -1,27 +1,57 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Serialization;
using StackExchange.Redis;
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using System.Text.RegularExpressions;
namespace allstarr.Services.Common;
/// <summary>
/// Redis caching service for metadata and images.
/// Tiered caching service: L1 in-memory (IMemoryCache, ~30s TTL) backed by
/// L2 Redis for persistence. The memory tier eliminates Redis network round-trips
/// for repeated reads within a short window (playlist scrolling, search-as-you-type).
/// </summary>
public class RedisCacheService
{
/// <summary>
/// Default L1 memory cache duration. Kept short to avoid serving stale data,
/// but long enough to absorb bursts of repeated reads.
/// </summary>
private static readonly TimeSpan DefaultMemoryTtl = TimeSpan.FromSeconds(30);
/// <summary>
/// Key prefixes that should NOT be cached in memory (e.g., large binary blobs).
/// </summary>
private static readonly string[] MemoryExcludedPrefixes = ["image:"];
private static readonly JsonSerializerOptions ReflectionFallbackJsonOptions = new()
{
PropertyNamingPolicy = null,
DictionaryKeyPolicy = null,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
private readonly RedisSettings _settings;
private readonly ILogger<RedisCacheService> _logger;
private readonly IMemoryCache _memoryCache;
private readonly ConcurrentDictionary<string, byte> _memoryKeys = new(StringComparer.Ordinal);
private IConnectionMultiplexer? _redis;
private IDatabase? _db;
private readonly object _lock = new();
public RedisCacheService(
IOptions<RedisSettings> settings,
ILogger<RedisCacheService> logger)
ILogger<RedisCacheService> logger,
IMemoryCache memoryCache)
{
_settings = settings.Value;
_logger = logger;
_memoryCache = memoryCache;
if (_settings.Enabled)
{
@@ -48,23 +78,145 @@ public class RedisCacheService
public bool IsEnabled => _settings.Enabled && _db != null;
/// <summary>
/// Gets a cached value as a string.
/// Checks whether a key should be cached in the L1 memory tier.
/// Large binary data (images) is excluded to avoid memory pressure.
/// </summary>
public async Task<string?> GetStringAsync(string key)
private static bool ShouldUseMemoryCache(string key)
{
if (!IsEnabled) return null;
foreach (var prefix in MemoryExcludedPrefixes)
{
if (key.StartsWith(prefix, StringComparison.Ordinal))
return false;
}
return true;
}
/// <summary>
/// Computes the L1 TTL for a key mirrored from Redis.
/// Returns null for already-expired entries, which skips L1 caching entirely.
/// </summary>
private static TimeSpan? GetMemoryTtl(TimeSpan? redisExpiry)
{
if (redisExpiry == null)
return DefaultMemoryTtl;
if (redisExpiry.Value <= TimeSpan.Zero)
return null;
return redisExpiry.Value < DefaultMemoryTtl ? redisExpiry.Value : DefaultMemoryTtl;
}
private bool TryGetMemoryValue(string key, out string? value)
{
if (!ShouldUseMemoryCache(key))
{
value = null;
return false;
}
return _memoryCache.TryGetValue(key, out value);
}
private void SetMemoryValue(string key, string value, TimeSpan? expiry)
{
if (!ShouldUseMemoryCache(key))
return;
var memoryTtl = GetMemoryTtl(expiry);
if (memoryTtl == null)
{
_memoryCache.Remove(key);
_memoryKeys.TryRemove(key, out _);
return;
}
var options = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = memoryTtl
};
options.RegisterPostEvictionCallback(
static (cacheKey, _, _, state) =>
{
if (cacheKey is string stringKey && state is ConcurrentDictionary<string, byte> memoryKeys)
{
memoryKeys.TryRemove(stringKey, out _);
}
},
_memoryKeys);
_memoryCache.Set(key, value, options);
_memoryKeys[key] = 0;
}
private int RemoveMemoryKeysByPattern(string pattern)
{
if (_memoryKeys.IsEmpty)
return 0;
if (!pattern.Contains('*') && !pattern.Contains('?'))
{
var removed = _memoryKeys.TryRemove(pattern, out _);
_memoryCache.Remove(pattern);
return removed ? 1 : 0;
}
var regex = new Regex(
"^" + Regex.Escape(pattern).Replace("\\*", ".*").Replace("\\?", ".") + "$",
RegexOptions.CultureInvariant);
var keysToRemove = _memoryKeys.Keys.Where(key => regex.IsMatch(key)).ToArray();
foreach (var key in keysToRemove)
{
_memoryCache.Remove(key);
_memoryKeys.TryRemove(key, out _);
}
return keysToRemove.Length;
}
/// <summary>
/// Gets a cached value as a string.
/// Checks L1 memory cache first, falls back to L2 Redis.
/// </summary>
public ValueTask<string?> GetStringAsync(string key)
{
// L1: Try in-memory cache first (sub-microsecond)
if (TryGetMemoryValue(key, out var memoryValue))
{
_logger.LogDebug("L1 memory cache HIT: {Key}", key);
return new ValueTask<string?>(memoryValue);
}
if (!IsEnabled) return new ValueTask<string?>((string?)null);
return new ValueTask<string?>(GetStringFromRedisAsync(key));
}
private async Task<string?> GetStringFromRedisAsync(string key)
{
try
{
// L2: Fall back to Redis
var value = await _db!.StringGetAsync(key);
if (value.HasValue)
{
_logger.LogDebug("Redis cache HIT: {Key}", key);
_logger.LogDebug("L2 Redis cache HIT: {Key}", key);
// Promote to L1 for subsequent reads
if (ShouldUseMemoryCache(key))
{
var stringValue = (string?)value;
if (stringValue != null)
{
var redisExpiry = await _db.KeyTimeToLiveAsync(key);
SetMemoryValue(key, stringValue, redisExpiry);
}
}
}
else
{
_logger.LogDebug("Redis cache MISS: {Key}", key);
_logger.LogDebug("Cache MISS: {Key}", key);
}
return value;
}
@@ -77,15 +229,17 @@ public class RedisCacheService
/// <summary>
/// Gets a cached value and deserializes it.
/// Uses source-generated serializer for registered types (3-8x faster),
/// with automatic fallback to reflection-based serialization.
/// </summary>
public async Task<T?> GetAsync<T>(string key) where T : class
public async ValueTask<T?> GetAsync<T>(string key) where T : class
{
var json = await GetStringAsync(key);
if (string.IsNullOrEmpty(json)) return null;
try
{
return JsonSerializer.Deserialize<T>(json);
return DeserializeWithFallback<T>(json, key);
}
catch (Exception ex)
{
@@ -96,11 +250,20 @@ public class RedisCacheService
/// <summary>
/// Sets a cached value with TTL.
/// Writes to both L1 memory cache and L2 Redis.
/// </summary>
public async Task<bool> SetStringAsync(string key, string value, TimeSpan? expiry = null)
public ValueTask<bool> SetStringAsync(string key, string value, TimeSpan? expiry = null)
{
if (!IsEnabled) return false;
// Always update L1 (even if Redis is down — provides degraded caching)
SetMemoryValue(key, value, expiry);
if (!IsEnabled) return new ValueTask<bool>(false);
return new ValueTask<bool>(SetStringWithRedisAsync(key, value, expiry));
}
private async Task<bool> SetStringWithRedisAsync(string key, string value, TimeSpan? expiry)
{
try
{
return await SetStringInternalAsync(key, value, expiry);
@@ -197,12 +360,14 @@ public class RedisCacheService
/// <summary>
/// Sets a cached value by serializing it with TTL.
/// Uses source-generated serializer for registered types (3-8x faster),
/// with automatic fallback to reflection-based serialization.
/// </summary>
public async Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null) where T : class
public async ValueTask<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null) where T : class
{
try
{
var json = JsonSerializer.Serialize(value);
var json = SerializeWithFallback(value, key);
return await SetStringAsync(key, json, expiry);
}
catch (Exception ex)
@@ -212,13 +377,80 @@ public class RedisCacheService
}
}
/// <summary>
/// Deletes a cached value.
/// </summary>
public async Task<bool> DeleteAsync(string key)
private T? DeserializeWithFallback<T>(string json, string key) where T : class
{
if (!IsEnabled) return false;
var typeInfo = TryGetTypeInfo<T>();
if (typeInfo != null)
{
try
{
return JsonSerializer.Deserialize(json, typeInfo);
}
catch (NotSupportedException ex)
{
_logger.LogDebug(
ex,
"Source-generated deserialization unsupported for key: {Key}; falling back to reflection.",
key);
}
}
return JsonSerializer.Deserialize<T>(json, ReflectionFallbackJsonOptions);
}
private string SerializeWithFallback<T>(T value, string key) where T : class
{
var typeInfo = TryGetTypeInfo<T>();
if (typeInfo != null)
{
try
{
return JsonSerializer.Serialize(value, typeInfo);
}
catch (NotSupportedException ex)
{
_logger.LogDebug(
ex,
"Source-generated serialization unsupported for key: {Key}; falling back to reflection.",
key);
}
}
return JsonSerializer.Serialize(value, ReflectionFallbackJsonOptions);
}
/// <summary>
/// Attempts to resolve a JsonTypeInfo from the AllstarrJsonContext source generator.
/// Returns null if the type isn't registered, triggering fallback to reflection.
/// </summary>
private static JsonTypeInfo<T>? TryGetTypeInfo<T>() where T : class
{
try
{
return (JsonTypeInfo<T>?)AllstarrJsonContext.Default.GetTypeInfo(typeof(T));
}
catch
{
return null;
}
}
/// <summary>
/// Deletes a cached value from both L1 memory and L2 Redis.
/// </summary>
public ValueTask<bool> DeleteAsync(string key)
{
// Always evict from L1
_memoryCache.Remove(key);
_memoryKeys.TryRemove(key, out _);
if (!IsEnabled) return new ValueTask<bool>(false);
return new ValueTask<bool>(DeleteFromRedisAsync(key));
}
private async Task<bool> DeleteFromRedisAsync(string key)
{
try
{
return await _db!.KeyDeleteAsync(key);
@@ -233,10 +465,20 @@ public class RedisCacheService
/// <summary>
/// Checks if a key exists.
/// </summary>
public async Task<bool> ExistsAsync(string key)
public ValueTask<bool> ExistsAsync(string key)
{
if (!IsEnabled) return false;
if (ShouldUseMemoryCache(key) && _memoryCache.TryGetValue(key, out _))
{
return new ValueTask<bool>(true);
}
if (!IsEnabled) return new ValueTask<bool>(false);
return new ValueTask<bool>(ExistsInRedisAsync(key));
}
private async Task<bool> ExistsInRedisAsync(string key)
{
try
{
return await _db!.KeyExistsAsync(key);
@@ -271,10 +513,16 @@ public class RedisCacheService
/// Deletes all keys matching a pattern (e.g., "search:*").
/// WARNING: Use with caution as this scans all keys.
/// </summary>
public async Task<int> DeleteByPatternAsync(string pattern)
public ValueTask<int> DeleteByPatternAsync(string pattern)
{
if (!IsEnabled) return 0;
var memoryDeleted = RemoveMemoryKeysByPattern(pattern);
if (!IsEnabled) return new ValueTask<int>(memoryDeleted);
return new ValueTask<int>(DeleteByPatternFromRedisAsync(pattern, memoryDeleted));
}
private async Task<int> DeleteByPatternFromRedisAsync(string pattern, int memoryDeleted)
{
try
{
var server = _redis!.GetServer(_redis.GetEndPoints().First());
@@ -282,18 +530,22 @@ public class RedisCacheService
if (keys.Length == 0)
{
_logger.LogDebug("No keys found matching pattern: {Pattern}", pattern);
return 0;
_logger.LogDebug("No Redis keys found matching pattern: {Pattern}", pattern);
return memoryDeleted;
}
var deleted = await _db!.KeyDeleteAsync(keys);
_logger.LogDebug("Deleted {Count} Redis keys matching pattern: {Pattern}", deleted, pattern);
_logger.LogDebug(
"Deleted {RedisCount} Redis keys and {MemoryCount} memory keys matching pattern: {Pattern}",
deleted,
memoryDeleted,
pattern);
return (int)deleted;
}
catch (Exception ex)
{
_logger.LogError(ex, "Redis DELETE BY PATTERN failed for pattern: {Pattern}", pattern);
return 0;
return memoryDeleted;
}
}
}
@@ -442,16 +442,18 @@ public class RoundRobinFallbackHelper
private void LogEndpointFailure(string baseUrl, Exception ex, bool willRetry)
{
var message = BuildFailureSummary(ex);
var isTimeoutOrCancellation = ex is TaskCanceledException or OperationCanceledException;
var verb = isTimeoutOrCancellation ? "request timed out" : "request failed";
if (willRetry)
{
_logger.LogWarning("{Service} request failed at {Endpoint}: {Error}. Trying next...",
_serviceName, baseUrl, message);
_logger.LogWarning("{Service} {Verb} at {Endpoint}: {Error}. Trying next...",
_serviceName, verb, baseUrl, message);
}
else
{
_logger.LogError("{Service} request failed at {Endpoint}: {Error}",
_serviceName, baseUrl, message);
_logger.LogError("{Service} {Verb} at {Endpoint}: {Error}",
_serviceName, verb, baseUrl, message);
}
_logger.LogDebug(ex, "{Service} detailed failure for endpoint {Endpoint}",
@@ -466,6 +468,16 @@ public class RoundRobinFallbackHelper
return $"{statusCode}: {httpRequestException.StatusCode.Value}";
}
if (ex is TaskCanceledException)
{
return "Timed out";
}
if (ex is OperationCanceledException)
{
return "Canceled";
}
return ex.Message;
}
@@ -0,0 +1,235 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
namespace allstarr.Services.Common;
/// <summary>
/// Loads supported flat .env keys into ASP.NET configuration so Docker/admin UI
/// updates stored in /app/.env take effect on the next application startup.
/// </summary>
public static class RuntimeEnvConfiguration
{
private static readonly IReadOnlyDictionary<string, string[]> ExactKeyMappings =
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["BACKEND_TYPE"] = ["Backend:Type"],
["ADMIN_BIND_ANY_IP"] = ["Admin:BindAnyIp"],
["ADMIN_TRUSTED_SUBNETS"] = ["Admin:TrustedSubnets"],
["ADMIN_ENABLE_ENV_EXPORT"] = ["Admin:EnableEnvExport"],
["CORS_ALLOWED_ORIGINS"] = ["Cors:AllowedOrigins"],
["CORS_ALLOWED_METHODS"] = ["Cors:AllowedMethods"],
["CORS_ALLOWED_HEADERS"] = ["Cors:AllowedHeaders"],
["CORS_ALLOW_CREDENTIALS"] = ["Cors:AllowCredentials"],
["SUBSONIC_URL"] = ["Subsonic:Url"],
["JELLYFIN_URL"] = ["Jellyfin:Url"],
["JELLYFIN_API_KEY"] = ["Jellyfin:ApiKey"],
["JELLYFIN_USER_ID"] = ["Jellyfin:UserId"],
["JELLYFIN_CLIENT_USERNAME"] = ["Jellyfin:ClientUsername"],
["JELLYFIN_LIBRARY_ID"] = ["Jellyfin:LibraryId"],
["LIBRARY_DOWNLOAD_PATH"] = ["Library:DownloadPath"],
["LIBRARY_KEPT_PATH"] = ["Library:KeptPath"],
["REDIS_ENABLED"] = ["Redis:Enabled"],
["REDIS_CONNECTION_STRING"] = ["Redis:ConnectionString"],
["SPOTIFY_IMPORT_ENABLED"] = ["SpotifyImport:Enabled"],
["SPOTIFY_IMPORT_SYNC_START_HOUR"] = ["SpotifyImport:SyncStartHour"],
["SPOTIFY_IMPORT_SYNC_START_MINUTE"] = ["SpotifyImport:SyncStartMinute"],
["SPOTIFY_IMPORT_SYNC_WINDOW_HOURS"] = ["SpotifyImport:SyncWindowHours"],
["SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS"] = ["SpotifyImport:MatchingIntervalHours"],
["SPOTIFY_IMPORT_PLAYLISTS"] = ["SpotifyImport:Playlists"],
["SPOTIFY_IMPORT_PLAYLIST_IDS"] = ["SpotifyImport:PlaylistIds"],
["SPOTIFY_IMPORT_PLAYLIST_NAMES"] = ["SpotifyImport:PlaylistNames"],
["SPOTIFY_IMPORT_PLAYLIST_LOCAL_TRACKS_POSITIONS"] = ["SpotifyImport:PlaylistLocalTracksPositions"],
["SPOTIFY_API_ENABLED"] = ["SpotifyApi:Enabled"],
["SPOTIFY_API_SESSION_COOKIE"] = ["SpotifyApi:SessionCookie"],
["SPOTIFY_API_SESSION_COOKIE_SET_DATE"] = ["SpotifyApi:SessionCookieSetDate"],
["SPOTIFY_API_CACHE_DURATION_MINUTES"] = ["SpotifyApi:CacheDurationMinutes"],
["SPOTIFY_API_RATE_LIMIT_DELAY_MS"] = ["SpotifyApi:RateLimitDelayMs"],
["SPOTIFY_API_PREFER_ISRC_MATCHING"] = ["SpotifyApi:PreferIsrcMatching"],
["SPOTIFY_LYRICS_API_URL"] = ["SpotifyApi:LyricsApiUrl"],
["SCROBBLING_ENABLED"] = ["Scrobbling:Enabled"],
["SCROBBLING_LOCAL_TRACKS_ENABLED"] = ["Scrobbling:LocalTracksEnabled"],
["SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED"] = ["Scrobbling:SyntheticLocalPlayedSignalEnabled"],
["SCROBBLING_LASTFM_ENABLED"] = ["Scrobbling:LastFm:Enabled"],
["SCROBBLING_LASTFM_API_KEY"] = ["Scrobbling:LastFm:ApiKey"],
["SCROBBLING_LASTFM_SHARED_SECRET"] = ["Scrobbling:LastFm:SharedSecret"],
["SCROBBLING_LASTFM_SESSION_KEY"] = ["Scrobbling:LastFm:SessionKey"],
["SCROBBLING_LASTFM_USERNAME"] = ["Scrobbling:LastFm:Username"],
["SCROBBLING_LASTFM_PASSWORD"] = ["Scrobbling:LastFm:Password"],
["SCROBBLING_LISTENBRAINZ_ENABLED"] = ["Scrobbling:ListenBrainz:Enabled"],
["SCROBBLING_LISTENBRAINZ_USER_TOKEN"] = ["Scrobbling:ListenBrainz:UserToken"],
["DEBUG_LOG_ALL_REQUESTS"] = ["Debug:LogAllRequests"],
["DEBUG_REDACT_SENSITIVE_REQUEST_VALUES"] = ["Debug:RedactSensitiveRequestValues"],
["DEEZER_ARL"] = ["Deezer:Arl"],
["DEEZER_ARL_FALLBACK"] = ["Deezer:ArlFallback"],
["DEEZER_QUALITY"] = ["Deezer:Quality"],
["DEEZER_MIN_REQUEST_INTERVAL_MS"] = ["Deezer:MinRequestIntervalMs"],
["QOBUZ_USER_AUTH_TOKEN"] = ["Qobuz:UserAuthToken"],
["QOBUZ_USER_ID"] = ["Qobuz:UserId"],
["QOBUZ_QUALITY"] = ["Qobuz:Quality"],
["QOBUZ_MIN_REQUEST_INTERVAL_MS"] = ["Qobuz:MinRequestIntervalMs"],
["SQUIDWTF_QUALITY"] = ["SquidWTF:Quality"],
["SQUIDWTF_MIN_REQUEST_INTERVAL_MS"] = ["SquidWTF:MinRequestIntervalMs"],
["MUSICBRAINZ_ENABLED"] = ["MusicBrainz:Enabled"],
["MUSICBRAINZ_USERNAME"] = ["MusicBrainz:Username"],
["MUSICBRAINZ_PASSWORD"] = ["MusicBrainz:Password"],
["CACHE_SEARCH_RESULTS_MINUTES"] = ["Cache:SearchResultsMinutes"],
["CACHE_PLAYLIST_IMAGES_HOURS"] = ["Cache:PlaylistImagesHours"],
["CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS"] = ["Cache:SpotifyPlaylistItemsHours"],
["CACHE_SPOTIFY_MATCHED_TRACKS_DAYS"] = ["Cache:SpotifyMatchedTracksDays"],
["CACHE_LYRICS_DAYS"] = ["Cache:LyricsDays"],
["CACHE_GENRE_DAYS"] = ["Cache:GenreDays"],
["CACHE_METADATA_DAYS"] = ["Cache:MetadataDays"],
["CACHE_ODESLI_LOOKUP_DAYS"] = ["Cache:OdesliLookupDays"],
["CACHE_PROXY_IMAGES_DAYS"] = ["Cache:ProxyImagesDays"],
["CACHE_TRANSCODE_MINUTES"] = ["Cache:TranscodeCacheMinutes"]
};
private static readonly IReadOnlyDictionary<string, string[]> SharedBackendKeyMappings =
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["MUSIC_SERVICE"] = ["Subsonic:MusicService", "Jellyfin:MusicService"],
["EXPLICIT_FILTER"] = ["Subsonic:ExplicitFilter", "Jellyfin:ExplicitFilter"],
["DOWNLOAD_MODE"] = ["Subsonic:DownloadMode", "Jellyfin:DownloadMode"],
["STORAGE_MODE"] = ["Subsonic:StorageMode", "Jellyfin:StorageMode"],
["CACHE_DURATION_HOURS"] = ["Subsonic:CacheDurationHours", "Jellyfin:CacheDurationHours"],
["ENABLE_EXTERNAL_PLAYLISTS"] = ["Subsonic:EnableExternalPlaylists", "Jellyfin:EnableExternalPlaylists"],
["PLAYLISTS_DIRECTORY"] = ["Subsonic:PlaylistsDirectory", "Jellyfin:PlaylistsDirectory"]
};
private static readonly HashSet<string> IgnoredComposeOnlyKeys = new(StringComparer.OrdinalIgnoreCase)
{
"DOWNLOAD_PATH",
"KEPT_PATH",
"CACHE_PATH",
"REDIS_DATA_PATH"
};
public static string ResolveEnvFilePath(IHostEnvironment environment)
{
return environment.IsDevelopment()
? Path.GetFullPath(Path.Combine(environment.ContentRootPath, "..", ".env"))
: "/app/.env";
}
public static void AddDotEnvOverrides(
ConfigurationManager configuration,
IHostEnvironment environment,
TextWriter? logWriter = null)
{
AddDotEnvOverrides(configuration, ResolveEnvFilePath(environment), logWriter);
}
public static void AddDotEnvOverrides(
ConfigurationManager configuration,
string envFilePath,
TextWriter? logWriter = null)
{
var overrides = LoadDotEnvOverrides(envFilePath);
if (overrides.Count == 0)
{
if (File.Exists(envFilePath))
{
logWriter?.WriteLine($"No supported runtime overrides found in {envFilePath}");
}
return;
}
configuration.AddInMemoryCollection(overrides);
logWriter?.WriteLine($"Loaded {overrides.Count} runtime override(s) from {envFilePath}");
}
public static Dictionary<string, string?> LoadDotEnvOverrides(string envFilePath)
{
var overrides = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
if (!File.Exists(envFilePath))
{
return overrides;
}
foreach (var line in File.ReadLines(envFilePath))
{
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#'))
{
continue;
}
var separatorIndex = line.IndexOf('=');
if (separatorIndex <= 0)
{
continue;
}
var envKey = line[..separatorIndex].Trim();
var envValue = StripQuotes(line[(separatorIndex + 1)..].Trim());
foreach (var mapping in MapEnvVarToConfiguration(envKey, envValue))
{
overrides[mapping.Key] = mapping.Value;
}
}
return overrides;
}
public static IEnumerable<KeyValuePair<string, string?>> MapEnvVarToConfiguration(string envKey, string? envValue)
{
if (string.IsNullOrWhiteSpace(envKey) || IgnoredComposeOnlyKeys.Contains(envKey))
{
yield break;
}
if (envKey.Contains("__", StringComparison.Ordinal))
{
yield return new KeyValuePair<string, string?>(envKey.Replace("__", ":"), envValue);
yield break;
}
if (SharedBackendKeyMappings.TryGetValue(envKey, out var sharedKeys))
{
foreach (var sharedKey in sharedKeys)
{
yield return new KeyValuePair<string, string?>(sharedKey, envValue);
}
yield break;
}
if (ExactKeyMappings.TryGetValue(envKey, out var configKeys))
{
foreach (var configKey in configKeys)
{
yield return new KeyValuePair<string, string?>(configKey, envValue);
}
}
}
private static string StripQuotes(string? value)
{
if (string.IsNullOrEmpty(value))
{
return value ?? string.Empty;
}
if (value.StartsWith('"') && value.EndsWith('"') && value.Length >= 2)
{
return value[1..^1];
}
return value;
}
}
@@ -0,0 +1,57 @@
using allstarr.Models.Settings;
namespace allstarr.Services.Common;
public static class SpotifyPlaylistScopeResolver
{
public static SpotifyPlaylistConfig? ResolveConfig(
SpotifyImportSettings settings,
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null)
{
if (!string.IsNullOrWhiteSpace(jellyfinPlaylistId))
{
var byJellyfinId = settings.GetPlaylistByJellyfinId(jellyfinPlaylistId.Trim());
if (byJellyfinId != null)
{
return byJellyfinId;
}
}
return settings.GetPlaylistByName(playlistName, userId, jellyfinPlaylistId);
}
public static string? GetUserId(SpotifyPlaylistConfig? playlist, string? fallbackUserId = null)
{
if (!string.IsNullOrWhiteSpace(playlist?.UserId))
{
return playlist.UserId.Trim();
}
// A configured playlist with no explicit owner is global. Do not
// accidentally scope its caches to whichever Jellyfin user made
// the current request.
if (playlist != null)
{
return null;
}
return string.IsNullOrWhiteSpace(fallbackUserId) ? null : fallbackUserId.Trim();
}
public static string? GetScopeId(SpotifyPlaylistConfig? playlist, string? fallbackScopeId = null)
{
if (!string.IsNullOrWhiteSpace(playlist?.JellyfinId))
{
return playlist.JellyfinId.Trim();
}
if (!string.IsNullOrWhiteSpace(playlist?.Id))
{
return playlist.Id.Trim();
}
return string.IsNullOrWhiteSpace(fallbackScopeId) ? null : fallbackScopeId.Trim();
}
}
@@ -135,10 +135,15 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
{
// Execute searches in parallel
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken);
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken);
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken);
var songsTask = songLimit > 0
? SearchSongsAsync(query, songLimit, cancellationToken)
: Task.FromResult(new List<Song>());
var albumsTask = albumLimit > 0
? SearchAlbumsAsync(query, albumLimit, cancellationToken)
: Task.FromResult(new List<Album>());
var artistsTask = artistLimit > 0
? SearchArtistsAsync(query, artistLimit, cancellationToken)
: Task.FromResult(new List<Artist>());
await Task.WhenAll(songsTask, albumsTask, artistsTask);
@@ -0,0 +1,165 @@
using allstarr.Models.Domain;
namespace allstarr.Services.Jellyfin;
/// <summary>
/// Resolves "Appears on" albums for external artists.
/// Prefers provider-supplied album lists when they expose non-primary releases and
/// falls back to deriving albums from artist track payloads when needed.
/// </summary>
public class ExternalArtistAppearancesService
{
private readonly IMusicMetadataService _metadataService;
private readonly ILogger<ExternalArtistAppearancesService> _logger;
public ExternalArtistAppearancesService(
IMusicMetadataService metadataService,
ILogger<ExternalArtistAppearancesService> logger)
{
_metadataService = metadataService;
_logger = logger;
}
public async Task<List<Album>> GetAppearsOnAlbumsAsync(
string provider,
string externalId,
CancellationToken cancellationToken = default)
{
var artistTask = _metadataService.GetArtistAsync(provider, externalId, cancellationToken);
var albumsTask = _metadataService.GetArtistAlbumsAsync(provider, externalId, cancellationToken);
var tracksTask = _metadataService.GetArtistTracksAsync(provider, externalId, cancellationToken);
await Task.WhenAll(artistTask, albumsTask, tracksTask);
var artist = await artistTask;
if (artist == null || string.IsNullOrWhiteSpace(artist.Name))
{
_logger.LogDebug(
"No external artist metadata available for appears-on lookup: provider={Provider}, externalId={ExternalId}",
provider,
externalId);
return new List<Album>();
}
var allArtistAlbums = await albumsTask;
var artistTracks = await tracksTask;
var appearsOnAlbums = new Dictionary<string, Album>(StringComparer.OrdinalIgnoreCase);
var albumsById = allArtistAlbums
.Where(album => !string.IsNullOrWhiteSpace(album.Id))
.GroupBy(album => album.Id, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
foreach (var album in allArtistAlbums)
{
if (!IsKnownNonPrimaryAlbum(album, artist))
{
continue;
}
AddAlbumIfMissing(appearsOnAlbums, album);
}
foreach (var track in artistTracks)
{
var album = TryCreateAlbumFromTrack(provider, track, artist, albumsById);
if (album == null)
{
continue;
}
AddAlbumIfMissing(appearsOnAlbums, album);
}
var resolvedAlbums = appearsOnAlbums.Values
.OrderByDescending(album => album.Year ?? int.MinValue)
.ThenBy(album => album.Title, StringComparer.OrdinalIgnoreCase)
.ToList();
_logger.LogDebug(
"Resolved {Count} external appears-on albums for artist {ArtistId}",
resolvedAlbums.Count,
artist.Id);
return resolvedAlbums;
}
private static Album? TryCreateAlbumFromTrack(
string provider,
Song track,
Artist artist,
IReadOnlyDictionary<string, Album> albumsById)
{
if (string.IsNullOrWhiteSpace(track.AlbumId) || string.IsNullOrWhiteSpace(track.Album))
{
return null;
}
if (albumsById.TryGetValue(track.AlbumId, out var knownAlbum))
{
return IsKnownNonPrimaryAlbum(knownAlbum, artist) ? knownAlbum : null;
}
if (string.IsNullOrWhiteSpace(track.AlbumArtist) || NamesEqual(track.AlbumArtist, artist.Name))
{
return null;
}
return new Album
{
Id = track.AlbumId,
Title = track.Album,
Artist = track.AlbumArtist,
Year = track.Year,
SongCount = track.TotalTracks,
CoverArtUrl = track.CoverArtUrl,
IsLocal = false,
ExternalProvider = provider,
ExternalId = ExtractExternalAlbumId(track.AlbumId, provider)
};
}
private static bool IsKnownNonPrimaryAlbum(Album album, Artist artist)
{
if (!string.IsNullOrWhiteSpace(album.ArtistId) && !string.IsNullOrWhiteSpace(artist.Id))
{
return !string.Equals(album.ArtistId, artist.Id, StringComparison.OrdinalIgnoreCase);
}
return !string.IsNullOrWhiteSpace(album.Artist) && !NamesEqual(album.Artist, artist.Name);
}
private static void AddAlbumIfMissing(IDictionary<string, Album> albums, Album album)
{
var key = BuildAlbumKey(album);
if (!albums.ContainsKey(key))
{
albums[key] = album;
}
}
private static string BuildAlbumKey(Album album)
{
if (!string.IsNullOrWhiteSpace(album.Id))
{
return album.Id;
}
return $"{album.Title}\u001f{album.Artist}";
}
private static string? ExtractExternalAlbumId(string albumId, string provider)
{
var prefix = $"ext-{provider}-album-";
return albumId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
? albumId[prefix.Length..]
: null;
}
private static bool NamesEqual(string? left, string? right)
{
return string.Equals(
left?.Trim(),
right?.Trim(),
StringComparison.OrdinalIgnoreCase);
}
}
+183 -150
View File
@@ -10,12 +10,21 @@ namespace allstarr.Services.Jellyfin;
/// <summary>
/// Handles proxying requests to the Jellyfin server and authentication.
/// Uses a named HttpClient ("JellyfinBackend") with SocketsHttpHandler for
/// TCP connection pooling across scoped instances.
/// </summary>
public class JellyfinProxyService
{
/// <summary>
/// The IHttpClientFactory registration name for the Jellyfin backend client.
/// Configured with SocketsHttpHandler for connection pooling in Program.cs.
/// </summary>
public const string HttpClientName = "JellyfinBackend";
private readonly HttpClient _httpClient;
private readonly JellyfinSettings _settings;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly JellyfinUserContextResolver _userContextResolver;
private readonly ILogger<JellyfinProxyService> _logger;
private readonly RedisCacheService _cache;
private string? _cachedMusicLibraryId;
@@ -28,16 +37,35 @@ public class JellyfinProxyService
IHttpClientFactory httpClientFactory,
IOptions<JellyfinSettings> settings,
IHttpContextAccessor httpContextAccessor,
JellyfinUserContextResolver userContextResolver,
ILogger<JellyfinProxyService> logger,
RedisCacheService cache)
{
_httpClient = httpClientFactory.CreateClient();
_httpClient = httpClientFactory.CreateClient(HttpClientName);
_settings = settings.Value;
_httpContextAccessor = httpContextAccessor;
_userContextResolver = userContextResolver;
_logger = logger;
_cache = cache;
}
private async Task AddResolvedUserIdAsync(
Dictionary<string, string> queryParams,
IHeaderDictionary? clientHeaders = null,
bool allowConfigurationFallback = true)
{
if (queryParams.ContainsKey("userId") || queryParams.ContainsKey("UserId"))
{
return;
}
var userId = await _userContextResolver.ResolveCurrentUserIdAsync(clientHeaders, allowConfigurationFallback);
if (!string.IsNullOrWhiteSpace(userId))
{
queryParams["userId"] = userId;
}
}
/// <summary>
/// Gets the music library ID, auto-detecting it if not configured.
/// </summary>
@@ -153,9 +181,73 @@ public class JellyfinProxyService
return await GetJsonAsyncInternal(finalUrl, clientHeaders);
}
/// <summary>
/// Sends a proxied GET request to Jellyfin and returns the raw upstream response without buffering the body.
/// Intended for transparent passthrough of large JSON payloads that Allstarr does not modify.
/// </summary>
public async Task<HttpResponseMessage> GetPassthroughResponseAsync(
string endpoint,
IHeaderDictionary? clientHeaders = null,
CancellationToken cancellationToken = default)
{
var url = BuildUrl(endpoint);
using var request = CreateClientGetRequest(url, clientHeaders, out var isBrowserStaticRequest, out var isPublicEndpoint);
ForwardPassthroughRequestHeaders(clientHeaders, request);
var response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (!response.IsSuccessStatusCode && !isBrowserStaticRequest && !isPublicEndpoint)
{
LogUpstreamFailure(HttpMethod.Get, response.StatusCode, url);
}
return response;
}
private async Task<(JsonDocument? Body, int StatusCode)> GetJsonAsyncInternal(string url, IHeaderDictionary? clientHeaders)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
using var request = CreateClientGetRequest(url, clientHeaders, out var isBrowserStaticRequest, out var isPublicEndpoint);
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
var statusCode = (int)response.StatusCode;
if (!response.IsSuccessStatusCode)
{
if (!isBrowserStaticRequest && !isPublicEndpoint)
{
LogUpstreamFailure(HttpMethod.Get, response.StatusCode, url);
}
// Try to parse error response to pass through to client
try
{
await using var errorStream = await response.Content.ReadAsStreamAsync();
var errorDoc = await JsonDocument.ParseAsync(errorStream);
return (errorDoc, statusCode);
}
catch (JsonException)
{
// Not valid JSON, return null
}
return (null, statusCode);
}
await using var stream = await response.Content.ReadAsStreamAsync();
return (await JsonDocument.ParseAsync(stream), statusCode);
}
private HttpRequestMessage CreateClientGetRequest(
string url,
IHeaderDictionary? clientHeaders,
out bool isBrowserStaticRequest,
out bool isPublicEndpoint)
{
var request = new HttpRequestMessage(HttpMethod.Get, url);
// Forward client IP address to Jellyfin so it can identify the real client
if (_httpContextAccessor.HttpContext != null)
@@ -168,10 +260,8 @@ public class JellyfinProxyService
}
}
bool authHeaderAdded = false;
// Check if this is a browser request for static assets (favicon, etc.)
bool isBrowserStaticRequest = url.Contains("/favicon.ico", StringComparison.OrdinalIgnoreCase) ||
isBrowserStaticRequest = url.Contains("/favicon.ico", StringComparison.OrdinalIgnoreCase) ||
url.Contains("/web/", StringComparison.OrdinalIgnoreCase) ||
(clientHeaders?.Any(h => h.Key.Equals("User-Agent", StringComparison.OrdinalIgnoreCase) &&
h.Value.ToString().Contains("Mozilla", StringComparison.OrdinalIgnoreCase)) == true &&
@@ -180,10 +270,12 @@ public class JellyfinProxyService
h.Value.ToString().Contains("document", StringComparison.OrdinalIgnoreCase))) == true);
// Check if this is a public endpoint that doesn't require authentication
bool isPublicEndpoint = url.Contains("/System/Info/Public", StringComparison.OrdinalIgnoreCase) ||
isPublicEndpoint = url.Contains("/System/Info/Public", StringComparison.OrdinalIgnoreCase) ||
url.Contains("/Branding/", StringComparison.OrdinalIgnoreCase) ||
url.Contains("/Startup/", StringComparison.OrdinalIgnoreCase);
var authHeaderAdded = false;
// Forward authentication headers from client if provided
if (clientHeaders != null && clientHeaders.Count > 0)
{
@@ -209,40 +301,35 @@ public class JellyfinProxyService
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var response = await _httpClient.SendAsync(request);
var statusCode = (int)response.StatusCode;
// Always parse the response, even for errors
// The caller needs to see 401s so the client can re-authenticate
var content = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
if (!isBrowserStaticRequest && !isPublicEndpoint)
{
LogUpstreamFailure(HttpMethod.Get, response.StatusCode, url);
return request;
}
// Try to parse error response to pass through to client
if (!string.IsNullOrWhiteSpace(content))
private static void ForwardPassthroughRequestHeaders(
IHeaderDictionary? clientHeaders,
HttpRequestMessage request)
{
try
if (clientHeaders == null || clientHeaders.Count == 0)
{
var errorDoc = JsonDocument.Parse(content);
return (errorDoc, statusCode);
}
catch
{
// Not valid JSON, return null
}
return;
}
return (null, statusCode);
if (clientHeaders.TryGetValue("Accept-Encoding", out var acceptEncoding) &&
acceptEncoding.Count > 0)
{
request.Headers.TryAddWithoutValidation("Accept-Encoding", acceptEncoding.ToArray());
}
return (JsonDocument.Parse(content), statusCode);
if (clientHeaders.TryGetValue("User-Agent", out var userAgent) &&
userAgent.Count > 0)
{
request.Headers.TryAddWithoutValidation("User-Agent", userAgent.ToArray());
}
if (clientHeaders.TryGetValue("Accept-Language", out var acceptLanguage) &&
acceptLanguage.Count > 0)
{
request.Headers.TryAddWithoutValidation("Accept-Language", acceptLanguage.ToArray());
}
}
/// <summary>
@@ -251,10 +338,31 @@ public class JellyfinProxyService
/// Returns the response body and HTTP status code.
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> PostJsonAsync(string endpoint, string body, IHeaderDictionary clientHeaders)
{
var bodyToSend = body;
if (string.IsNullOrWhiteSpace(bodyToSend))
{
bodyToSend = "{}";
_logger.LogWarning("POST body was empty for {Endpoint}, sending empty JSON object", endpoint);
}
return await SendAsync(HttpMethod.Post, endpoint, bodyToSend, clientHeaders, "application/json");
}
/// <summary>
/// Sends an arbitrary HTTP request to Jellyfin while preserving the caller's method and body semantics.
/// Intended for transparent proxy scenarios such as session control routes.
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> SendAsync(
HttpMethod method,
string endpoint,
string? body,
IHeaderDictionary clientHeaders,
string? contentType = null)
{
var url = BuildUrl(endpoint, null);
using var request = new HttpRequestMessage(HttpMethod.Post, url);
using var request = new HttpRequestMessage(method, url);
// Forward client IP address to Jellyfin so it can identify the real client
if (_httpContextAccessor.HttpContext != null)
@@ -267,58 +375,62 @@ public class JellyfinProxyService
}
}
// Handle special case for playback endpoints
// NOTE: Jellyfin API expects PlaybackStartInfo/PlaybackProgressInfo/PlaybackStopInfo
// DIRECTLY as the body, NOT wrapped in a field. Do NOT wrap the body.
var bodyToSend = body;
if (string.IsNullOrWhiteSpace(body))
if (body != null)
{
bodyToSend = "{}";
_logger.LogWarning("POST body was empty for {Url}, sending empty JSON object", url);
var requestContent = new StringContent(body, System.Text.Encoding.UTF8);
try
{
requestContent.Headers.ContentType = !string.IsNullOrWhiteSpace(contentType)
? MediaTypeHeaderValue.Parse(contentType)
: new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
}
catch (FormatException)
{
_logger.LogWarning("Invalid content type '{ContentType}' for {Method} {Endpoint}; falling back to application/json",
contentType,
method,
endpoint);
requestContent.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
}
request.Content = new StringContent(bodyToSend, System.Text.Encoding.UTF8, "application/json");
request.Content = requestContent;
}
bool authHeaderAdded = false;
bool isAuthEndpoint = endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase);
// Forward authentication headers from client
authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
var authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
var isAuthEndpoint = endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase);
if (authHeaderAdded)
{
_logger.LogTrace("Forwarded authentication headers");
}
// For authentication endpoints, credentials are in the body, not headers
// For other endpoints without auth, let Jellyfin reject the request
if (!authHeaderAdded && !isAuthEndpoint)
else if (!isAuthEndpoint)
{
_logger.LogDebug("No client auth provided for POST {Url} - Jellyfin will handle authentication", url);
_logger.LogDebug("No client auth provided for {Method} {Url} - Jellyfin will handle authentication", method, url);
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// DO NOT log the body for auth endpoints - it contains passwords!
if (isAuthEndpoint)
{
_logger.LogDebug("POST to Jellyfin: {Url} (auth request - body not logged)", url);
_logger.LogDebug("{Method} to Jellyfin: {Url} (auth request - body not logged)", method, url);
}
else if (body == null)
{
_logger.LogTrace("{Method} to Jellyfin: {Url} (no request body)", method, url);
}
else
{
_logger.LogTrace("POST to Jellyfin: {Url}, body length: {Length} bytes", url, bodyToSend.Length);
_logger.LogTrace("{Method} to Jellyfin: {Url}, body length: {Length} bytes", method, url, body.Length);
}
var response = await _httpClient.SendAsync(request);
var statusCode = (int)response.StatusCode;
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
LogUpstreamFailure(HttpMethod.Post, response.StatusCode, url, errorContent);
LogUpstreamFailure(method, response.StatusCode, url, errorContent);
// Try to parse error response as JSON to pass through to client
if (!string.IsNullOrWhiteSpace(errorContent))
{
try
@@ -335,21 +447,17 @@ public class JellyfinProxyService
return (null, statusCode);
}
// Log successful session-related responses
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase))
{
_logger.LogTrace("Jellyfin responded {StatusCode} for {Endpoint}", statusCode, endpoint);
_logger.LogTrace("Jellyfin responded {StatusCode} for {Method} {Endpoint}", statusCode, method, endpoint);
}
// Handle 204 No Content responses (e.g., /sessions/playing, /sessions/playing/progress)
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
if (response.StatusCode == HttpStatusCode.NoContent)
{
return (null, statusCode);
}
var responseContent = await response.Content.ReadAsStringAsync();
// Handle empty responses
if (string.IsNullOrWhiteSpace(responseContent))
{
return (null, statusCode);
@@ -411,65 +519,7 @@ public class JellyfinProxyService
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> DeleteAsync(string endpoint, IHeaderDictionary clientHeaders)
{
var url = BuildUrl(endpoint, null);
using var request = new HttpRequestMessage(HttpMethod.Delete, url);
// Forward client IP address to Jellyfin so it can identify the real client
if (_httpContextAccessor.HttpContext != null)
{
var clientIp = _httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.ToString();
if (!string.IsNullOrEmpty(clientIp))
{
request.Headers.TryAddWithoutValidation("X-Forwarded-For", clientIp);
request.Headers.TryAddWithoutValidation("X-Real-IP", clientIp);
}
}
bool authHeaderAdded = false;
// Forward authentication headers from client
authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
if (!authHeaderAdded)
{
_logger.LogDebug("No client auth provided for DELETE {Url} - forwarding without auth", url);
}
else
{
_logger.LogTrace("Forwarded authentication headers");
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_logger.LogDebug("DELETE to Jellyfin: {Url}", url);
var response = await _httpClient.SendAsync(request);
var statusCode = (int)response.StatusCode;
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
LogUpstreamFailure(HttpMethod.Delete, response.StatusCode, url, errorContent);
return (null, statusCode);
}
// Handle 204 No Content responses
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
{
return (null, statusCode);
}
var responseContent = await response.Content.ReadAsStringAsync();
// Handle empty responses
if (string.IsNullOrWhiteSpace(responseContent))
{
return (null, statusCode);
}
return (JsonDocument.Parse(responseContent), statusCode);
return await SendAsync(HttpMethod.Delete, endpoint, null, clientHeaders);
}
/// <summary>
@@ -517,10 +567,7 @@ public class JellyfinProxyService
["fields"] = "PrimaryImageAspectRatio,MediaSources,Path,Genres,Studios,DateCreated,Overview,ProviderIds"
};
if (!string.IsNullOrEmpty(_settings.UserId))
{
queryParams["userId"] = _settings.UserId;
}
await AddResolvedUserIdAsync(queryParams, clientHeaders);
// Note: We don't force parentId here - let clients specify which library to search
// The controller will detect music library searches and add external results
@@ -567,10 +614,7 @@ public class JellyfinProxyService
["fields"] = "PrimaryImageAspectRatio,MediaSources,Path,Genres,Studios,DateCreated,Overview,ProviderIds,ParentId"
};
if (!string.IsNullOrEmpty(_settings.UserId))
{
queryParams["userId"] = _settings.UserId;
}
await AddResolvedUserIdAsync(queryParams, clientHeaders);
if (!string.IsNullOrEmpty(parentId))
{
@@ -612,10 +656,7 @@ public class JellyfinProxyService
{
var queryParams = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(_settings.UserId))
{
queryParams["userId"] = _settings.UserId;
}
await AddResolvedUserIdAsync(queryParams, clientHeaders);
return await GetJsonAsync($"Items/{itemId}", queryParams, clientHeaders);
}
@@ -634,10 +675,7 @@ public class JellyfinProxyService
["fields"] = "PrimaryImageAspectRatio,Genres,Overview"
};
if (!string.IsNullOrEmpty(_settings.UserId))
{
queryParams["userId"] = _settings.UserId;
}
await AddResolvedUserIdAsync(queryParams, clientHeaders);
if (!string.IsNullOrEmpty(searchTerm))
{
@@ -664,10 +702,7 @@ public class JellyfinProxyService
{
var queryParams = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(_settings.UserId))
{
queryParams["userId"] = _settings.UserId;
}
await AddResolvedUserIdAsync(queryParams, clientHeaders);
// Try to get by ID first
if (Guid.TryParse(artistIdOrName, out _))
@@ -858,10 +893,7 @@ public class JellyfinProxyService
try
{
var queryParams = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(_settings.UserId))
{
queryParams["userId"] = _settings.UserId;
}
await AddResolvedUserIdAsync(queryParams);
var (result, statusCode) = await GetJsonAsync("Library/MediaFolders", queryParams);
if (result == null)
@@ -982,12 +1014,12 @@ public class JellyfinProxyService
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var response = await _httpClient.SendAsync(request);
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
var statusCode = (int)response.StatusCode;
var content = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Jellyfin internal request returned {StatusCode} for {Url}: {Content}",
statusCode, url, content);
return (null, statusCode);
@@ -995,12 +1027,13 @@ public class JellyfinProxyService
try
{
var jsonDocument = JsonDocument.Parse(content);
await using var stream = await response.Content.ReadAsStreamAsync();
var jsonDocument = await JsonDocument.ParseAsync(stream);
return (jsonDocument, statusCode);
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse JSON response from {Url}: {Content}", url, content);
_logger.LogError(ex, "Failed to parse JSON response from {Url}", url);
return (null, statusCode);
}
}
@@ -355,6 +355,7 @@ public class JellyfinResponseBuilder
["Tags"] = new string[0],
["People"] = new object[0],
["SortName"] = songTitle,
["AudioInfo"] = new Dictionary<string, object?>(),
["ParentLogoItemId"] = song.AlbumId,
["ParentBackdropItemId"] = song.AlbumId,
["ParentBackdropImageTags"] = new string[0],
@@ -405,6 +406,7 @@ public class JellyfinResponseBuilder
["MediaType"] = "Audio",
["NormalizationGain"] = 0.0,
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
["CanDelete"] = false,
["CanDownload"] = true,
["SupportsSync"] = true
};
@@ -539,6 +541,7 @@ public class JellyfinResponseBuilder
["ServerId"] = "allstarr",
["Id"] = album.Id,
["PremiereDate"] = album.Year.HasValue ? $"{album.Year}-01-01T05:00:00.0000000Z" : null,
["DateCreated"] = album.Year.HasValue ? $"{album.Year}-01-01T05:00:00.0000000Z" : "1970-01-01T00:00:00.0000000Z",
["ChannelId"] = (object?)null,
["Genres"] = !string.IsNullOrEmpty(album.Genre)
? new[] { album.Genre }
@@ -547,6 +550,8 @@ public class JellyfinResponseBuilder
["ProductionYear"] = album.Year,
["IsFolder"] = true,
["Type"] = "MusicAlbum",
["SortName"] = albumName,
["BasicSyncInfo"] = new Dictionary<string, object?>(),
["GenreItems"] = !string.IsNullOrEmpty(album.Genre)
? new[]
{
@@ -633,6 +638,9 @@ public class JellyfinResponseBuilder
["RunTimeTicks"] = 0,
["IsFolder"] = true,
["Type"] = "MusicArtist",
["SortName"] = artistName,
["PrimaryImageAspectRatio"] = 1.0,
["BasicSyncInfo"] = new Dictionary<string, object?>(),
["GenreItems"] = new Dictionary<string, object?>[0],
["UserData"] = new Dictionary<string, object>
{
@@ -755,6 +763,11 @@ public class JellyfinResponseBuilder
["RunTimeTicks"] = playlist.Duration * TimeSpan.TicksPerSecond,
["IsFolder"] = true,
["Type"] = "MusicAlbum",
["SortName"] = $"{playlist.Name} [S/P]",
["DateCreated"] = playlist.CreatedDate.HasValue
? playlist.CreatedDate.Value.ToString("o")
: "1970-01-01T00:00:00.0000000Z",
["BasicSyncInfo"] = new Dictionary<string, object?>(),
["GenreItems"] = new Dictionary<string, object?>[0],
["UserData"] = new Dictionary<string, object>
{
@@ -1,10 +1,11 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using allstarr.Models.Jellyfin;
using allstarr.Models.Settings;
using allstarr.Serialization;
namespace allstarr.Services.Jellyfin;
@@ -20,6 +21,7 @@ public class JellyfinSessionManager : IDisposable
private readonly ILogger<JellyfinSessionManager> _logger;
private readonly ConcurrentDictionary<string, SessionInfo> _sessions = new();
private readonly ConcurrentDictionary<string, SemaphoreSlim> _sessionInitLocks = new();
private readonly ConcurrentDictionary<string, byte> _proxiedWebSocketConnections = new();
private readonly Timer _keepAliveTimer;
public JellyfinSessionManager(
@@ -53,14 +55,20 @@ public class JellyfinSessionManager : IDisposable
await initLock.WaitAsync();
try
{
var hasProxiedWebSocket = HasProxiedWebSocket(deviceId);
// Check if we already have this session tracked
if (_sessions.TryGetValue(deviceId, out var existingSession))
{
existingSession.LastActivity = DateTime.UtcNow;
existingSession.HasProxiedWebSocket = hasProxiedWebSocket;
_logger.LogInformation("Session already exists for device {DeviceId}", deviceId);
// Refresh capabilities to keep session alive
// If this returns false (401), the token expired and client needs to re-auth
if (!hasProxiedWebSocket)
{
// Refresh capabilities to keep session alive only for sessions that Allstarr
// is synthesizing itself. Native proxied websocket sessions should be left
// entirely under Jellyfin's control.
var refreshOk = await PostCapabilitiesAsync(headers);
if (!refreshOk)
{
@@ -69,13 +77,18 @@ public class JellyfinSessionManager : IDisposable
await RemoveSessionAsync(deviceId);
return false;
}
}
return true;
}
_logger.LogDebug("Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
// Post session capabilities to Jellyfin - this creates the session
if (!hasProxiedWebSocket)
{
// Post session capabilities to Jellyfin only when Allstarr is creating a
// synthetic session. If the real client already has a proxied websocket,
// re-posting capabilities can overwrite its remote-control state.
var createOk = await PostCapabilitiesAsync(headers);
if (!createOk)
{
@@ -85,6 +98,12 @@ public class JellyfinSessionManager : IDisposable
}
_logger.LogInformation("Session created for {DeviceId}", deviceId);
}
else
{
_logger.LogDebug("Skipping synthetic Jellyfin session bootstrap for proxied websocket device {DeviceId}",
deviceId);
}
// Track this session
var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
@@ -99,11 +118,16 @@ public class JellyfinSessionManager : IDisposable
Version = version,
LastActivity = DateTime.UtcNow,
Headers = CloneHeaders(headers),
ClientIp = clientIp
ClientIp = clientIp,
HasProxiedWebSocket = hasProxiedWebSocket
};
// Start a WebSocket connection to Jellyfin on behalf of this client
// Start a synthetic WebSocket connection only when the client itself does not
// already have a proxied Jellyfin socket through Allstarr.
if (!hasProxiedWebSocket)
{
_ = Task.Run(() => MaintainWebSocketForSessionAsync(deviceId, headers));
}
return true;
}
@@ -118,27 +142,65 @@ public class JellyfinSessionManager : IDisposable
}
}
public async Task RegisterProxiedWebSocketAsync(string deviceId)
{
if (string.IsNullOrWhiteSpace(deviceId))
{
return;
}
_proxiedWebSocketConnections[deviceId] = 0;
if (_sessions.TryGetValue(deviceId, out var session))
{
session.HasProxiedWebSocket = true;
session.LastActivity = DateTime.UtcNow;
await CloseSyntheticWebSocketAsync(deviceId, session);
}
}
public void UnregisterProxiedWebSocket(string deviceId)
{
if (string.IsNullOrWhiteSpace(deviceId))
{
return;
}
_proxiedWebSocketConnections.TryRemove(deviceId, out _);
if (_sessions.TryGetValue(deviceId, out var session))
{
session.HasProxiedWebSocket = false;
session.LastActivity = DateTime.UtcNow;
}
}
private bool HasProxiedWebSocket(string deviceId)
{
return !string.IsNullOrWhiteSpace(deviceId) && _proxiedWebSocketConnections.ContainsKey(deviceId);
}
/// <summary>
/// Posts session capabilities to Jellyfin.
/// Returns true if successful, false if token expired (401).
/// </summary>
private async Task<bool> PostCapabilitiesAsync(IHeaderDictionary headers)
{
var capabilities = new
{
PlayableMediaTypes = new[] { "Audio" },
SupportedCommands = new[]
var capabilities = new JellyfinSessionCapabilitiesPayload
{
PlayableMediaTypes = ["Audio"],
SupportedCommands =
[
"Play",
"Playstate",
"PlayNext"
},
],
SupportsMediaControl = true,
SupportsPersistentIdentifier = true,
SupportsSync = false
};
var json = JsonSerializer.Serialize(capabilities);
var json = AllstarrJsonSerializer.Serialize(capabilities);
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", json, headers);
if (statusCode == 204 || statusCode == 200)
@@ -345,8 +407,10 @@ public class JellyfinSessionManager : IDisposable
ClientIp = s.ClientIp,
LastActivity = s.LastActivity,
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
HasWebSocket = s.WebSocket != null,
WebSocketState = s.WebSocket?.State.ToString() ?? "None"
HasWebSocket = s.HasProxiedWebSocket || s.WebSocket != null,
HasProxiedWebSocket = s.HasProxiedWebSocket,
HasSyntheticWebSocket = s.WebSocket != null,
WebSocketState = s.HasProxiedWebSocket ? "Proxied" : s.WebSocket?.State.ToString() ?? "None"
}).ToList();
return new
@@ -363,6 +427,8 @@ public class JellyfinSessionManager : IDisposable
/// </summary>
public async Task RemoveSessionAsync(string deviceId)
{
_proxiedWebSocketConnections.TryRemove(deviceId, out _);
if (_sessions.TryRemove(deviceId, out var session))
{
_logger.LogDebug("🗑️ SESSION: Removing session for device {DeviceId}", deviceId);
@@ -390,12 +456,12 @@ public class JellyfinSessionManager : IDisposable
// Report playback stopped to Jellyfin if we have a playing item (for scrobbling)
if (!string.IsNullOrEmpty(session.LastPlayingItemId))
{
var stopPayload = new
var stopPayload = new JellyfinPlaybackStatePayload
{
ItemId = session.LastPlayingItemId,
PositionTicks = session.LastPlayingPositionTicks ?? 0
};
var stopJson = JsonSerializer.Serialize(stopPayload);
var stopJson = AllstarrJsonSerializer.Serialize(stopPayload);
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers);
_logger.LogInformation("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
@@ -422,6 +488,12 @@ public class JellyfinSessionManager : IDisposable
return;
}
if (session.HasProxiedWebSocket || HasProxiedWebSocket(deviceId))
{
_logger.LogDebug("Skipping synthetic Jellyfin websocket for proxied device {DeviceId}", deviceId);
return;
}
ClientWebSocket? webSocket = null;
try
@@ -525,6 +597,13 @@ public class JellyfinSessionManager : IDisposable
{
try
{
if (HasProxiedWebSocket(deviceId))
{
_logger.LogDebug("Stopping synthetic Jellyfin websocket because proxied client websocket is active for {DeviceId}",
deviceId);
break;
}
// Use a timeout so we can send keep-alive messages periodically
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
@@ -635,6 +714,12 @@ public class JellyfinSessionManager : IDisposable
{
try
{
session.HasProxiedWebSocket = HasProxiedWebSocket(session.DeviceId);
if (session.HasProxiedWebSocket)
{
continue;
}
// Post capabilities again to keep session alive
// If this returns false (401), the token has expired
var success = await PostCapabilitiesAsync(session.Headers);
@@ -695,6 +780,7 @@ public class JellyfinSessionManager : IDisposable
public string? LastLocalPlayedSignalItemId { get; set; }
public string? LastExplicitStopItemId { get; set; }
public DateTime? LastExplicitStopAtUtc { get; set; }
public bool HasProxiedWebSocket { get; set; }
}
public sealed record ActivePlaybackState(
@@ -729,4 +815,31 @@ public class JellyfinSessionManager : IDisposable
}
}
}
private async Task CloseSyntheticWebSocketAsync(string deviceId, SessionInfo session)
{
var syntheticSocket = session.WebSocket;
if (syntheticSocket == null)
{
return;
}
session.WebSocket = null;
try
{
if (syntheticSocket.State == WebSocketState.Open || syntheticSocket.State == WebSocketState.CloseReceived)
{
await syntheticSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Native client websocket active", CancellationToken.None);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to close synthetic Jellyfin websocket for proxied device {DeviceId}", deviceId);
}
finally
{
syntheticSocket.Dispose();
}
}
}
@@ -0,0 +1,155 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using allstarr.Models.Settings;
using allstarr.Services.Admin;
using allstarr.Services.Common;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
namespace allstarr.Services.Jellyfin;
/// <summary>
/// Resolves the effective Jellyfin user for the current request.
/// Prefers explicit request/session context and falls back to the legacy configured user id.
/// </summary>
public class JellyfinUserContextResolver
{
private static readonly TimeSpan TokenLookupCacheTtl = TimeSpan.FromMinutes(5);
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IHttpClientFactory _httpClientFactory;
private readonly JellyfinSettings _settings;
private readonly IMemoryCache _memoryCache;
private readonly ILogger<JellyfinUserContextResolver> _logger;
public JellyfinUserContextResolver(
IHttpContextAccessor httpContextAccessor,
IHttpClientFactory httpClientFactory,
IOptions<JellyfinSettings> settings,
IMemoryCache memoryCache,
ILogger<JellyfinUserContextResolver> logger)
{
_httpContextAccessor = httpContextAccessor;
_httpClientFactory = httpClientFactory;
_settings = settings.Value;
_memoryCache = memoryCache;
_logger = logger;
}
public async Task<string?> ResolveCurrentUserIdAsync(
IHeaderDictionary? headers = null,
bool allowConfigurationFallback = true,
CancellationToken cancellationToken = default)
{
var httpContext = _httpContextAccessor.HttpContext;
var request = httpContext?.Request;
headers ??= request?.Headers;
var explicitUserId = request?.RouteValues["userId"]?.ToString();
if (string.IsNullOrWhiteSpace(explicitUserId))
{
explicitUserId = request?.Query["userId"].ToString();
}
if (!string.IsNullOrWhiteSpace(explicitUserId))
{
return explicitUserId.Trim();
}
if (httpContext?.Items.TryGetValue(AdminAuthSessionService.HttpContextSessionItemKey, out var sessionObj) == true &&
sessionObj is AdminAuthSession session &&
!string.IsNullOrWhiteSpace(session.UserId))
{
return session.UserId.Trim();
}
if (headers != null)
{
var headerUserId = AuthHeaderHelper.ExtractUserId(headers);
if (!string.IsNullOrWhiteSpace(headerUserId))
{
return headerUserId.Trim();
}
var token = AuthHeaderHelper.ExtractAccessToken(headers);
if (!string.IsNullOrWhiteSpace(token))
{
var cacheKey = BuildTokenCacheKey(token);
if (_memoryCache.TryGetValue(cacheKey, out string? cachedUserId) &&
!string.IsNullOrWhiteSpace(cachedUserId))
{
return cachedUserId;
}
var resolvedUserId = await ResolveUserIdFromJellyfinAsync(headers, cancellationToken);
if (!string.IsNullOrWhiteSpace(resolvedUserId))
{
_memoryCache.Set(cacheKey, resolvedUserId.Trim(), TokenLookupCacheTtl);
return resolvedUserId.Trim();
}
}
}
if (allowConfigurationFallback && !string.IsNullOrWhiteSpace(_settings.UserId))
{
_logger.LogDebug("Falling back to configured Jellyfin user id for current request scope");
return _settings.UserId.Trim();
}
return null;
}
private async Task<string?> ResolveUserIdFromJellyfinAsync(
IHeaderDictionary headers,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_settings.Url))
{
return null;
}
try
{
using var request = new HttpRequestMessage(
HttpMethod.Get,
$"{_settings.Url.TrimEnd('/')}/Users/Me");
if (!AuthHeaderHelper.ForwardAuthHeaders(headers, request))
{
return null;
}
request.Headers.Accept.ParseAdd("application/json");
var client = _httpClientFactory.CreateClient(JellyfinProxyService.HttpClientName);
using var response = await client.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger.LogDebug("Failed to resolve Jellyfin user from token via /Users/Me: {StatusCode}",
response.StatusCode);
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
if (doc.RootElement.TryGetProperty("Id", out var idProp))
{
var userId = idProp.GetString();
return string.IsNullOrWhiteSpace(userId) ? null : userId.Trim();
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error resolving Jellyfin user from auth token");
}
return null;
}
private static string BuildTokenCacheKey(string token)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(token));
return $"jellyfin:user-from-token:{Convert.ToHexString(hash)}";
}
}
@@ -160,9 +160,15 @@ public class QobuzMetadataService : TrackParserBase, IMusicMetadataService
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
{
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken);
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken);
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken);
var songsTask = songLimit > 0
? SearchSongsAsync(query, songLimit, cancellationToken)
: Task.FromResult(new List<Song>());
var albumsTask = albumLimit > 0
? SearchAlbumsAsync(query, albumLimit, cancellationToken)
: Task.FromResult(new List<Album>());
var artistsTask = artistLimit > 0
? SearchArtistsAsync(query, artistLimit, cancellationToken)
: Task.FromResult(new List<Artist>());
await Task.WhenAll(songsTask, albumsTask, artistsTask);
@@ -29,7 +29,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
private readonly RedisCacheService _cache;
// Track Spotify playlist IDs after discovery
private readonly Dictionary<string, string> _playlistNameToSpotifyId = new();
private readonly Dictionary<string, string> _playlistScopeToSpotifyId = new();
public SpotifyPlaylistFetcher(
ILogger<SpotifyPlaylistFetcher> logger,
@@ -55,10 +55,20 @@ public class SpotifyPlaylistFetcher : BackgroundService
/// </summary>
/// <param name="playlistName">Playlist name (e.g., "Release Radar", "Discover Weekly")</param>
/// <returns>List of tracks in playlist order, or empty list if not found</returns>
public async Task<List<SpotifyPlaylistTrack>> GetPlaylistTracksAsync(string playlistName)
public async Task<List<SpotifyPlaylistTrack>> GetPlaylistTracksAsync(
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null)
{
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName);
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
var playlistConfig = SpotifyPlaylistScopeResolver.ResolveConfig(
_spotifyImportSettings,
playlistName,
userId,
jellyfinPlaylistId);
var playlistScopeUserId = SpotifyPlaylistScopeResolver.GetUserId(playlistConfig, userId);
var playlistScopeId = SpotifyPlaylistScopeResolver.GetScopeId(playlistConfig, jellyfinPlaylistId);
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName, playlistScopeUserId, playlistScopeId);
var playlistScope = CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, playlistScopeUserId, playlistScopeId);
// Try Redis cache first
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
@@ -124,14 +134,14 @@ public class SpotifyPlaylistFetcher : BackgroundService
try
{
// Try to use cached or configured Spotify playlist ID
if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId))
if (!_playlistScopeToSpotifyId.TryGetValue(playlistScope, out var spotifyId))
{
// Check if we have a configured Spotify ID for this playlist
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.Id))
{
// Use the configured Spotify playlist ID directly
spotifyId = playlistConfig.Id;
_playlistNameToSpotifyId[playlistName] = spotifyId;
_playlistScopeToSpotifyId[playlistScope] = spotifyId;
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
}
else
@@ -150,7 +160,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
}
spotifyId = exactMatch.SpotifyId;
_playlistNameToSpotifyId[playlistName] = spotifyId;
_playlistScopeToSpotifyId[playlistScope] = spotifyId;
_logger.LogInformation("Found Spotify playlist '{Name}' with ID: {Id}", playlistName, spotifyId);
}
}
@@ -226,7 +236,8 @@ public class SpotifyPlaylistFetcher : BackgroundService
string playlistName,
HashSet<string> jellyfinTrackIds)
{
var allTracks = await GetPlaylistTracksAsync(playlistName);
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
var allTracks = await GetPlaylistTracksAsync(playlistName, playlistConfig?.UserId, playlistConfig?.JellyfinId);
// Filter to only tracks not in Jellyfin, preserving order
return allTracks
@@ -237,16 +248,30 @@ public class SpotifyPlaylistFetcher : BackgroundService
/// <summary>
/// Manual trigger to refresh a specific playlist.
/// </summary>
public async Task RefreshPlaylistAsync(string playlistName)
public async Task RefreshPlaylistAsync(
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null)
{
_logger.LogInformation("Manual refresh triggered for playlist '{Name}'", playlistName);
// Clear cache to force refresh
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName);
var playlistConfig = SpotifyPlaylistScopeResolver.ResolveConfig(
_spotifyImportSettings,
playlistName,
userId,
jellyfinPlaylistId);
var playlistScopeUserId = SpotifyPlaylistScopeResolver.GetUserId(playlistConfig, userId);
var playlistScopeId = SpotifyPlaylistScopeResolver.GetScopeId(playlistConfig, jellyfinPlaylistId);
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
await _cache.DeleteAsync(cacheKey);
// Re-fetch
await GetPlaylistTracksAsync(playlistName);
await GetPlaylistTracksAsync(playlistName, playlistScopeUserId, playlistConfig?.JellyfinId ?? jellyfinPlaylistId);
await ClearPlaylistImageCacheAsync(playlistName, userId, jellyfinPlaylistId);
}
/// <summary>
@@ -258,10 +283,31 @@ public class SpotifyPlaylistFetcher : BackgroundService
foreach (var config in _spotifyImportSettings.Playlists)
{
await RefreshPlaylistAsync(config.Name);
await RefreshPlaylistAsync(config.Name, config.UserId, config.JellyfinId);
}
}
private async Task ClearPlaylistImageCacheAsync(
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null)
{
var playlistConfig = SpotifyPlaylistScopeResolver.ResolveConfig(
_spotifyImportSettings,
playlistName,
userId,
jellyfinPlaylistId);
if (playlistConfig == null || string.IsNullOrWhiteSpace(playlistConfig.JellyfinId))
{
return;
}
var deletedCount = await _cache.DeleteByPatternAsync($"image:{playlistConfig.JellyfinId}:*");
_logger.LogDebug("Cleared {Count} cached local image entries for playlist {Playlist}",
deletedCount,
playlistName);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("========================================");
@@ -316,7 +362,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
{
// Check each playlist to see if it needs refreshing based on cron schedule
var now = DateTime.UtcNow;
var needsRefresh = new List<string>();
var needsRefresh = new List<SpotifyPlaylistConfig>();
foreach (var config in _spotifyImportSettings.Playlists)
{
@@ -327,7 +373,10 @@ public class SpotifyPlaylistFetcher : BackgroundService
var cron = CronExpression.Parse(schedule);
// Check if we have cached data
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(config.Name);
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(
config.Name,
config.UserId,
config.JellyfinId ?? config.Id);
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
if (cached != null)
@@ -337,7 +386,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
if (nextRun.HasValue && now >= nextRun.Value)
{
needsRefresh.Add(config.Name);
needsRefresh.Add(config);
_logger.LogInformation("Playlist '{Name}' needs refresh - last fetched {Age:F1}h ago, next run was {NextRun}",
config.Name, (now - cached.FetchedAt).TotalHours, nextRun.Value);
}
@@ -345,7 +394,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
else
{
// No cache, fetch it
needsRefresh.Add(config.Name);
needsRefresh.Add(config);
}
}
catch (Exception ex)
@@ -359,24 +408,24 @@ public class SpotifyPlaylistFetcher : BackgroundService
{
_logger.LogInformation("=== CRON TRIGGER: Fetching {Count} playlists ===", needsRefresh.Count);
foreach (var playlistName in needsRefresh)
foreach (var config in needsRefresh)
{
if (stoppingToken.IsCancellationRequested) break;
try
{
await GetPlaylistTracksAsync(playlistName);
await GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
// Rate limiting between playlists
if (playlistName != needsRefresh.Last())
if (!ReferenceEquals(config, needsRefresh.Last()))
{
_logger.LogWarning("Finished fetching '{Name}' - waiting 3 seconds before next playlist to avoid rate limits...", playlistName);
_logger.LogWarning("Finished fetching '{Name}' - waiting 3 seconds before next playlist to avoid rate limits...", config.Name);
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching playlist '{Name}'", playlistName);
_logger.LogError(ex, "Error fetching playlist '{Name}'", config.Name);
}
}
@@ -404,7 +453,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
try
{
var tracks = await GetPlaylistTracksAsync(config.Name);
var tracks = await GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
_logger.LogDebug(" {Name}: {Count} tracks", config.Name, tracks.Count);
// Log sample of track order for debugging
@@ -1,6 +1,7 @@
using allstarr.Models.Domain;
using allstarr.Models.Settings;
using allstarr.Models.Spotify;
using allstarr.Services.Admin;
using allstarr.Services.Common;
using allstarr.Services.Jellyfin;
using Microsoft.AspNetCore.Http;
@@ -72,6 +73,24 @@ public class SpotifyTrackMatchingService : BackgroundService
return true;
}
private static string? GetPlaylistScopeUserId(SpotifyPlaylistConfig? playlist) =>
SpotifyPlaylistScopeResolver.GetUserId(playlist);
private static string? GetPlaylistScopeId(SpotifyPlaylistConfig? playlist) =>
SpotifyPlaylistScopeResolver.GetScopeId(playlist);
private SpotifyPlaylistConfig? ResolvePlaylistConfig(
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null) =>
SpotifyPlaylistScopeResolver.ResolveConfig(_spotifySettings, playlistName, userId, jellyfinPlaylistId);
private static string BuildPlaylistRunKey(SpotifyPlaylistConfig playlist) =>
CacheKeyBuilder.BuildSpotifyPlaylistScope(
playlist.Name,
GetPlaylistScopeUserId(playlist),
GetPlaylistScopeId(playlist));
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("========================================");
@@ -121,7 +140,7 @@ public class SpotifyTrackMatchingService : BackgroundService
// Use a small grace window so we don't miss exact-minute cron runs when waking slightly late.
var now = DateTime.UtcNow;
var schedulerReference = now.AddMinutes(-1);
var nextRuns = new List<(string PlaylistName, DateTime NextRun, CronExpression Cron)>();
var nextRuns = new List<(SpotifyPlaylistConfig Playlist, DateTime NextRun, CronExpression Cron)>();
foreach (var playlist in _spotifySettings.Playlists)
{
@@ -134,7 +153,7 @@ public class SpotifyTrackMatchingService : BackgroundService
if (nextRun.HasValue)
{
nextRuns.Add((playlist.Name, nextRun.Value, cron));
nextRuns.Add((playlist, nextRun.Value, cron));
}
else
{
@@ -169,7 +188,7 @@ public class SpotifyTrackMatchingService : BackgroundService
var waitTime = nextPlaylist.NextRun - now;
_logger.LogInformation("Next scheduled run: {Playlist} at {Time} UTC (in {Minutes:F1} minutes)",
nextPlaylist.PlaylistName, nextPlaylist.NextRun, waitTime.TotalMinutes);
nextPlaylist.Playlist.Name, nextPlaylist.NextRun, waitTime.TotalMinutes);
var maxWait = TimeSpan.FromHours(1);
var actualWait = waitTime > maxWait ? maxWait : waitTime;
@@ -190,10 +209,10 @@ public class SpotifyTrackMatchingService : BackgroundService
break;
}
_logger.LogInformation("→ Running scheduled rebuild for {Playlist}", due.PlaylistName);
_logger.LogInformation("→ Running scheduled rebuild for {Playlist}", due.Playlist.Name);
var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync(
due.PlaylistName,
due.Playlist,
stoppingToken,
trigger: "cron");
@@ -204,7 +223,7 @@ public class SpotifyTrackMatchingService : BackgroundService
}
_logger.LogInformation("✓ Finished scheduled rebuild for {Playlist} - Next run at {NextRun} UTC",
due.PlaylistName, due.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
due.Playlist.Name, due.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
}
// Avoid a tight loop if one or more due playlists were skipped by cooldown.
@@ -225,29 +244,24 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Rebuilds a single playlist from scratch (clears cache, fetches fresh data, re-matches).
/// Used by individual per-playlist rebuild actions.
/// </summary>
private async Task RebuildSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
private async Task RebuildSinglePlaylistAsync(SpotifyPlaylistConfig playlist, CancellationToken cancellationToken)
{
var playlist = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
if (playlist == null)
{
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
return;
}
var playlistScopeUserId = GetPlaylistScopeUserId(playlist);
var playlistScopeId = GetPlaylistScopeId(playlist);
var playlistName = playlist.Name;
_logger.LogInformation("Step 1/3: Clearing cache for {Playlist}", playlistName);
// Clear cache for this playlist (same as "Rebuild All Remote" button)
var keysToDelete = new[]
{
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlist.Name), // Legacy key
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyPlaylistOrderedKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(playlist.Name)
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name, playlistScopeUserId, playlistScopeId),
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name, playlistScopeUserId, playlistScopeId),
CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlist.Name, playlistScopeUserId, playlistScopeId),
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name, playlistScopeUserId, playlistScopeId),
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name, playlistScopeUserId, playlistScopeId),
CacheKeyBuilder.BuildSpotifyPlaylistOrderedKey(playlist.Name, playlistScopeUserId, playlistScopeId),
CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(playlist.Name, playlistScopeUserId, playlistScopeId)
};
foreach (var key in keysToDelete)
@@ -268,7 +282,7 @@ public class SpotifyTrackMatchingService : BackgroundService
if (playlistFetcher != null)
{
// Force refresh from Spotify (clears cache and re-fetches)
await playlistFetcher.RefreshPlaylistAsync(playlist.Name);
await playlistFetcher.RefreshPlaylistAsync(playlist.Name, playlistScopeUserId, playlist.JellyfinId);
}
}
@@ -280,13 +294,13 @@ public class SpotifyTrackMatchingService : BackgroundService
{
// Use new direct API mode with ISRC support
await MatchPlaylistTracksWithIsrcAsync(
playlist.Name, playlistFetcher, metadataService, cancellationToken);
playlist, playlistFetcher, metadataService, cancellationToken);
}
else
{
// Fall back to legacy mode
await MatchPlaylistTracksLegacyAsync(
playlist.Name, metadataService, cancellationToken);
playlist, metadataService, cancellationToken);
}
}
catch (Exception ex)
@@ -295,6 +309,7 @@ public class SpotifyTrackMatchingService : BackgroundService
throw;
}
await ClearPlaylistImageCacheAsync(playlist);
_logger.LogInformation("✓ Rebuild complete for {Playlist}", playlistName);
}
@@ -302,16 +317,9 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Matches tracks for a single playlist WITHOUT clearing cache or refreshing from Spotify.
/// Used for lightweight re-matching when only local library has changed.
/// </summary>
private async Task MatchSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
private async Task MatchSinglePlaylistAsync(SpotifyPlaylistConfig playlist, CancellationToken cancellationToken)
{
var playlist = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
if (playlist == null)
{
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
return;
}
var playlistName = playlist.Name;
using var scope = _serviceProvider.CreateScope();
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
@@ -329,14 +337,16 @@ public class SpotifyTrackMatchingService : BackgroundService
{
// Use new direct API mode with ISRC support
await MatchPlaylistTracksWithIsrcAsync(
playlist.Name, playlistFetcher, metadataService, cancellationToken);
playlist, playlistFetcher, metadataService, cancellationToken);
}
else
{
// Fall back to legacy mode
await MatchPlaylistTracksLegacyAsync(
playlist.Name, metadataService, cancellationToken);
playlist, metadataService, cancellationToken);
}
await ClearPlaylistImageCacheAsync(playlist);
}
catch (Exception ex)
{
@@ -345,6 +355,19 @@ public class SpotifyTrackMatchingService : BackgroundService
}
}
private async Task ClearPlaylistImageCacheAsync(SpotifyPlaylistConfig playlist)
{
if (string.IsNullOrWhiteSpace(playlist.JellyfinId))
{
return;
}
var deletedCount = await _cache.DeleteByPatternAsync($"image:{playlist.JellyfinId}:*");
_logger.LogDebug("Cleared {Count} cached local image entries for playlist {Playlist}",
deletedCount,
playlist.Name);
}
/// <summary>
/// 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.
@@ -359,17 +382,28 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Public method to trigger full rebuild for a single playlist (called from individual "Rebuild Remote" button).
/// This clears cache, fetches fresh data, and re-matches - same workflow as scheduled cron rebuilds for a playlist.
/// </summary>
public async Task TriggerRebuildForPlaylistAsync(string playlistName)
public async Task TriggerRebuildForPlaylistAsync(
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null)
{
_logger.LogInformation("Manual full rebuild triggered for playlist: {Playlist}", playlistName);
var playlist = ResolvePlaylistConfig(playlistName, userId, jellyfinPlaylistId);
if (playlist == null)
{
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
return;
}
var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync(
playlistName,
playlist,
CancellationToken.None,
trigger: "manual");
if (!rebuilt)
{
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
var runKey = BuildPlaylistRunKey(playlist);
if (_lastRunTimes.TryGetValue(runKey, out var lastRun))
{
var timeSinceLastRun = DateTime.UtcNow - lastRun;
var remaining = _minimumRunInterval - timeSinceLastRun;
@@ -383,11 +417,12 @@ public class SpotifyTrackMatchingService : BackgroundService
}
private async Task<bool> TryRunSinglePlaylistRebuildWithCooldownAsync(
string playlistName,
SpotifyPlaylistConfig playlist,
CancellationToken cancellationToken,
string trigger)
{
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
var runKey = BuildPlaylistRunKey(playlist);
if (_lastRunTimes.TryGetValue(runKey, out var lastRun))
{
var timeSinceLastRun = DateTime.UtcNow - lastRun;
if (timeSinceLastRun < _minimumRunInterval)
@@ -395,15 +430,15 @@ public class SpotifyTrackMatchingService : BackgroundService
_logger.LogWarning(
"Skipping {Trigger} rebuild for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
trigger,
playlistName,
playlist.Name,
(int)timeSinceLastRun.TotalSeconds,
(int)_minimumRunInterval.TotalSeconds);
return false;
}
}
await RebuildSinglePlaylistAsync(playlistName, cancellationToken);
_lastRunTimes[playlistName] = DateTime.UtcNow;
await RebuildSinglePlaylistAsync(playlist, cancellationToken);
_lastRunTimes[runKey] = DateTime.UtcNow;
return true;
}
@@ -423,14 +458,23 @@ public class SpotifyTrackMatchingService : BackgroundService
/// This bypasses cron schedules and runs immediately WITHOUT clearing cache or refreshing from Spotify.
/// Use this when only the local library has changed, not when Spotify playlist changed.
/// </summary>
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
public async Task TriggerMatchingForPlaylistAsync(
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null)
{
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist} (lightweight, no cache clear)", playlistName);
var playlist = ResolvePlaylistConfig(playlistName, userId, jellyfinPlaylistId);
if (playlist == null)
{
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
return;
}
// Intentionally no cooldown here: this path should react immediately to
// local library changes and manual mapping updates without waiting for
// Spotify API cooldown windows.
await MatchSinglePlaylistAsync(playlistName, CancellationToken.None);
await MatchSinglePlaylistAsync(playlist, CancellationToken.None);
}
private async Task RebuildAllPlaylistsAsync(CancellationToken cancellationToken)
@@ -450,7 +494,7 @@ public class SpotifyTrackMatchingService : BackgroundService
try
{
await RebuildSinglePlaylistAsync(playlist.Name, cancellationToken);
await RebuildSinglePlaylistAsync(playlist, cancellationToken);
}
catch (Exception ex)
{
@@ -478,7 +522,7 @@ public class SpotifyTrackMatchingService : BackgroundService
try
{
await MatchSinglePlaylistAsync(playlist.Name, cancellationToken);
await MatchSinglePlaylistAsync(playlist, cancellationToken);
}
catch (Exception ex)
{
@@ -496,15 +540,25 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Uses GREEDY ASSIGNMENT to maximize total matches.
/// </summary>
private async Task MatchPlaylistTracksWithIsrcAsync(
string playlistName,
SpotifyPlaylistConfig playlistConfig,
SpotifyPlaylistFetcher playlistFetcher,
IMusicMetadataService metadataService,
CancellationToken cancellationToken)
{
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName);
var playlist = playlistConfig ?? throw new ArgumentNullException(nameof(playlistConfig));
var playlistName = playlist.Name;
var playlistScopeUserId = GetPlaylistScopeUserId(playlist);
var playlistScopeId = GetPlaylistScopeId(playlist);
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
// Get playlist tracks with full metadata including ISRC and position
var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(playlistName);
var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(
playlistName,
playlistScopeUserId,
playlist.JellyfinId);
if (spotifyTracks.Count == 0)
{
_logger.LogWarning("No tracks found for {Playlist}, skipping matching", playlistName);
@@ -512,12 +566,10 @@ public class SpotifyTrackMatchingService : BackgroundService
}
// Get the Jellyfin playlist ID to check which tracks already exist
var playlistConfig = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
HashSet<string> existingSpotifyIds = new();
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
if (!string.IsNullOrEmpty(playlist.JellyfinId))
{
// Get existing tracks from Jellyfin playlist to avoid re-matching
using var scope = _serviceProvider.CreateScope();
@@ -529,8 +581,9 @@ public class SpotifyTrackMatchingService : BackgroundService
try
{
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
var userId = jellyfinSettings.UserId;
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
var userId = playlist.UserId ?? jellyfinSettings.UserId;
var jellyfinPlaylistId = playlist.JellyfinId;
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items";
var queryParams = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(userId))
{
@@ -612,10 +665,18 @@ public class SpotifyTrackMatchingService : BackgroundService
foreach (var track in tracksToMatch)
{
// Check if this track has a manual mapping but isn't in the cached results
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(playlistName, track.SpotifyId);
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
playlistName,
track.SpotifyId,
playlistScopeUserId,
playlistScopeId);
var manualMapping = await _cache.GetAsync<string>(manualMappingKey);
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(playlistName, track.SpotifyId);
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
playlistName,
track.SpotifyId,
playlistScopeUserId,
playlistScopeId);
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
var hasManualMapping = !string.IsNullOrEmpty(manualMapping) || !string.IsNullOrEmpty(externalMappingJson);
@@ -643,7 +704,7 @@ public class SpotifyTrackMatchingService : BackgroundService
// PHASE 1: Get ALL Jellyfin tracks from the playlist (already injected by plugin)
var jellyfinTracks = new List<Song>();
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
if (!string.IsNullOrEmpty(playlist.JellyfinId))
{
using var scope = _serviceProvider.CreateScope();
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
@@ -654,8 +715,9 @@ public class SpotifyTrackMatchingService : BackgroundService
{
try
{
var userId = jellyfinSettings.UserId;
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
var userId = playlist.UserId ?? jellyfinSettings.UserId;
var jellyfinPlaylistId = playlist.JellyfinId;
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items";
var queryParams = new Dictionary<string, string> { ["Fields"] = CachedPlaylistItemFields };
if (!string.IsNullOrEmpty(userId))
{
@@ -926,19 +988,19 @@ public class SpotifyTrackMatchingService : BackgroundService
["missing"] = statsMissingCount
};
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(playlistName);
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
await _cache.SetAsync(statsCacheKey, stats, TimeSpan.FromMinutes(30));
_logger.LogInformation("📊 Updated stats cache for {Playlist}: {Local} local, {External} external, {Missing} missing",
playlistName, statsLocalCount, statsExternalCount, statsMissingCount);
// Calculate cache expiration: until next cron run (not just cache duration from settings)
var playlist = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
var cacheExpiration = TimeSpan.FromHours(24); // Default 24 hours
if (playlist != null && !string.IsNullOrEmpty(playlist.SyncSchedule))
if (!string.IsNullOrEmpty(playlist.SyncSchedule))
{
try
{
@@ -965,10 +1027,13 @@ public class SpotifyTrackMatchingService : BackgroundService
await _cache.SetAsync(matchedTracksKey, matchedTracks, cacheExpiration);
// Save matched tracks to file for persistence across restarts
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks, playlistScopeUserId, playlistScopeId);
// Also update legacy cache for backward compatibility
var legacyKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlistName);
var legacyKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
await _cache.SetAsync(legacyKey, legacySongs, cacheExpiration);
@@ -978,7 +1043,7 @@ public class SpotifyTrackMatchingService : BackgroundService
// Pre-build playlist items cache for instant serving
// This is what makes the UI show all matched tracks at once
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cacheExpiration, cancellationToken);
await PreBuildPlaylistItemsCacheAsync(playlistName, playlist.JellyfinId, spotifyTracks, matchedTracks, cacheExpiration, cancellationToken);
}
else
{
@@ -1249,12 +1314,21 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Legacy matching mode using MissingTrack from Jellyfin plugin.
/// </summary>
private async Task MatchPlaylistTracksLegacyAsync(
string playlistName,
SpotifyPlaylistConfig playlistConfig,
IMusicMetadataService metadataService,
CancellationToken cancellationToken)
{
var missingTracksKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName);
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlistName);
var playlistName = playlistConfig.Name;
var playlistScopeUserId = GetPlaylistScopeUserId(playlistConfig);
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
var missingTracksKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
// Check if we already have matched tracks cached
var existingMatched = await _cache.GetAsync<List<Song>>(matchedTracksKey);
@@ -1361,6 +1435,10 @@ public class SpotifyTrackMatchingService : BackgroundService
{
try
{
var playlistConfig = _spotifySettings.GetPlaylistByName(playlistName, jellyfinPlaylistId: jellyfinPlaylistId);
var playlistScopeUserId = GetPlaylistScopeUserId(playlistConfig);
var playlistScopeId = GetPlaylistScopeId(playlistConfig) ?? jellyfinPlaylistId;
_logger.LogDebug("🔨 Pre-building playlist items cache for {Playlist}...", playlistName);
if (string.IsNullOrEmpty(jellyfinPlaylistId))
@@ -1381,7 +1459,7 @@ public class SpotifyTrackMatchingService : BackgroundService
return;
}
var userId = jellyfinSettings.UserId;
var userId = playlistConfig?.UserId ?? jellyfinSettings.UserId;
if (string.IsNullOrEmpty(userId))
{
_logger.LogError("No UserId configured, cannot pre-build playlist cache for {Playlist}", playlistName);
@@ -1457,7 +1535,11 @@ public class SpotifyTrackMatchingService : BackgroundService
string? matchedKey = null;
// FIRST: Check for manual Jellyfin mapping
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(playlistName, spotifyTrack.SpotifyId);
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
playlistName,
spotifyTrack.SpotifyId,
playlistScopeUserId,
playlistScopeId);
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId))
@@ -1537,7 +1619,11 @@ public class SpotifyTrackMatchingService : BackgroundService
}
// SECOND: Check for external manual mapping
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(playlistName, spotifyTrack.SpotifyId);
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
playlistName,
spotifyTrack.SpotifyId,
playlistScopeUserId,
playlistScopeId);
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
if (!string.IsNullOrEmpty(externalMappingJson))
@@ -1825,11 +1911,14 @@ public class SpotifyTrackMatchingService : BackgroundService
}
// Save to Redis cache with same expiration as matched tracks (until next cron run)
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlistName);
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);
// Save to file cache for persistence
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
await SavePlaylistItemsToFileAsync(playlistName, finalItems, playlistScopeUserId, playlistScopeId);
var manualMappingInfo = "";
if (manualExternalCount > 0)
@@ -1854,14 +1943,19 @@ public class SpotifyTrackMatchingService : BackgroundService
/// <summary>
/// Saves playlist items to file cache for persistence across restarts.
/// </summary>
private async Task SavePlaylistItemsToFileAsync(string playlistName, List<Dictionary<string, object?>> items)
private async Task SavePlaylistItemsToFileAsync(
string playlistName,
List<Dictionary<string, object?>> items,
string? userId = null,
string? scopeId = null)
{
try
{
var cacheDir = "/app/cache/spotify";
Directory.CreateDirectory(cacheDir);
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var safeName = AdminHelperService.SanitizeFileName(
CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, userId, scopeId));
var filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
@@ -1878,14 +1972,19 @@ public class SpotifyTrackMatchingService : BackgroundService
/// <summary>
/// Saves matched tracks to file cache for persistence across restarts.
/// </summary>
private async Task SaveMatchedTracksToFileAsync(string playlistName, List<MatchedTrack> matchedTracks)
private async Task SaveMatchedTracksToFileAsync(
string playlistName,
List<MatchedTrack> matchedTracks,
string? userId = null,
string? scopeId = null)
{
try
{
var cacheDir = "/app/cache/spotify";
Directory.CreateDirectory(cacheDir);
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var safeName = AdminHelperService.SanitizeFileName(
CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, userId, scopeId));
var filePath = Path.Combine(cacheDir, $"{safeName}_matched.json");
var json = JsonSerializer.Serialize(matchedTracks, new JsonSerializerOptions { WriteIndented = true });
@@ -498,10 +498,15 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
{
// Execute searches in parallel
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken);
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken);
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken);
var songsTask = songLimit > 0
? SearchSongsAsync(query, songLimit, cancellationToken)
: Task.FromResult(new List<Song>());
var albumsTask = albumLimit > 0
? SearchAlbumsAsync(query, albumLimit, cancellationToken)
: Task.FromResult(new List<Album>());
var artistsTask = artistLimit > 0
? SearchArtistsAsync(query, artistLimit, cancellationToken)
: Task.FromResult(new List<Artist>());
await Task.WhenAll(songsTask, albumsTask, artistsTask);
+60 -2
View File
@@ -12,7 +12,7 @@
<!-- Restart Required Banner -->
<div class="restart-banner" id="restart-banner">
⚠️ Configuration changed. Restart required to apply changes.
<button onclick="restartContainer()">Restart Now</button>
<button onclick="restartContainer()">Restart Allstarr</button>
<button onclick="dismissRestartBanner()"
style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button>
</div>
@@ -32,6 +32,14 @@
<div class="auth-error" id="auth-error" role="alert"></div>
</form>
</div>
<div class="support-badge">
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
supporting its development via
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
or
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
</div>
</div>
<div class="container" id="main-container" style="display:none;">
@@ -858,13 +866,53 @@
</p>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button class="danger" onclick="clearCache()">Clear All Cache</button>
<button class="danger" onclick="restartContainer()">Restart Container</button>
<button class="danger" onclick="restartContainer()">Restart Allstarr</button>
</div>
</div>
</div>
<!-- API Analytics Tab -->
<div class="tab-content" id="tab-endpoints">
<div class="card">
<h2>
SquidWTF Endpoint Health
<div class="actions">
<button class="primary" onclick="fetchSquidWtfEndpointHealth(true)">Test Endpoints</button>
</div>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Runs a real SquidWTF API search probe and a real SquidWTF streaming manifest probe against every configured mirror.
Green means the API request worked. Blue means the streaming request worked.
</p>
<div class="endpoint-health-toolbar">
<div class="endpoint-health-legend">
<span><span class="endpoint-health-dot api up"></span> API up</span>
<span><span class="endpoint-health-dot streaming up"></span> Streaming up</span>
<span><span class="endpoint-health-dot down"></span> Down</span>
<span><span class="endpoint-health-dot unknown"></span> Not tested</span>
</div>
<div class="endpoint-health-last-tested" id="squidwtf-endpoints-tested-at">Not tested yet</div>
</div>
<div style="max-height: 520px; overflow-y: auto;">
<table class="playlist-table endpoint-health-table">
<thead>
<tr>
<th>Host</th>
<th style="width: 72px; text-align: center;">API</th>
<th style="width: 96px; text-align: center;">Streaming</th>
</tr>
</thead>
<tbody id="squidwtf-endpoints-table-body">
<tr>
<td colspan="3" class="loading">
Click <strong>Test Endpoints</strong> to probe SquidWTF mirrors.
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<h2>
API Endpoint Usage
@@ -954,6 +1002,16 @@
</p>
</div>
</div>
<footer class="support-footer">
<p>
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
supporting its development via
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
or
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
</p>
</footer>
</div>
<!-- Add Playlist Modal -->
+9 -1
View File
@@ -274,7 +274,7 @@ export async function restartContainer() {
return requestJson(
"/api/admin/restart",
{ method: "POST" },
"Failed to restart container",
"Failed to restart Allstarr",
);
}
@@ -414,6 +414,14 @@ export async function getSquidWTFBaseUrl() {
);
}
export async function fetchSquidWtfEndpointHealth() {
return requestJson(
"/api/admin/squidwtf/endpoints/test",
{ method: "POST" },
"Failed to test SquidWTF endpoints",
);
}
export async function fetchScrobblingStatus() {
return requestJson(
"/api/admin/scrobbling/status",
+34
View File
@@ -300,6 +300,33 @@ async function clearEndpointUsage() {
}
}
async function fetchSquidWtfEndpointHealth(showFeedback = false) {
const tbody = document.getElementById("squidwtf-endpoints-table-body");
if (tbody) {
tbody.innerHTML =
'<tr><td colspan="3" class="loading"><span class="spinner"></span> Testing SquidWTF endpoints...</td></tr>';
}
try {
const data = await API.fetchSquidWtfEndpointHealth();
UI.updateSquidWtfEndpointHealthUI(data);
if (showFeedback) {
showToast("SquidWTF endpoint test completed", "success");
}
} catch (error) {
console.error("Failed to test SquidWTF endpoints:", error);
if (tbody) {
tbody.innerHTML =
'<tr><td colspan="3" style="text-align:center;color:var(--error);padding:40px;">Failed to test SquidWTF endpoints</td></tr>';
}
if (showFeedback) {
showToast("Failed to test SquidWTF endpoints", "error");
}
}
}
function startPlaylistAutoRefresh() {
if (playlistAutoRefreshInterval) {
clearInterval(playlistAutoRefreshInterval);
@@ -370,6 +397,11 @@ async function loadDashboardData() {
fetchEndpointUsage(),
]);
const endpointsTab = document.getElementById("tab-endpoints");
if (endpointsTab && endpointsTab.classList.contains("active")) {
await fetchSquidWtfEndpointHealth(false);
}
// Ensure user filter defaults are populated before loading Link Playlists rows.
await fetchJellyfinUsers();
await fetchJellyfinPlaylists();
@@ -529,6 +561,7 @@ export function initDashboardData(options) {
window.fetchJellyfinUsers = fetchJellyfinUsers;
window.fetchEndpointUsage = fetchEndpointUsage;
window.clearEndpointUsage = clearEndpointUsage;
window.fetchSquidWtfEndpointHealth = fetchSquidWtfEndpointHealth;
return {
stopDashboardRefresh,
@@ -540,5 +573,6 @@ export function initDashboardData(options) {
fetchJellyfinPlaylists,
fetchConfig,
fetchStatus,
fetchSquidWtfEndpointHealth,
};
}
+10
View File
@@ -179,6 +179,16 @@ document.addEventListener("DOMContentLoaded", () => {
});
}
const endpointsTab = document.querySelector('.tab[data-tab="endpoints"]');
if (endpointsTab) {
endpointsTab.addEventListener("click", () => {
if (authSession.isAuthenticated() && authSession.isAdminSession()) {
window.fetchEndpointUsage?.();
window.fetchSquidWtfEndpointHealth?.(false);
}
});
}
authSession.bootstrapAuth();
});
+4 -4
View File
@@ -270,7 +270,7 @@ async function importEnv(event) {
const result = await runAction({
confirmMessage:
"Import this .env file? This will replace your current configuration.\n\nA backup will be created automatically.\n\nYou will need to restart the container for changes to take effect.",
"Import this .env file? This will replace your current configuration.\n\nA backup will be created automatically.\n\nYou will need to restart Allstarr for changes to take effect.",
task: () => API.importEnv(file),
success: (data) => data.message,
error: (err) => err.message || "Failed to import .env file",
@@ -283,7 +283,7 @@ async function importEnv(event) {
async function restartContainer() {
if (
!confirm(
"Restart the container to apply configuration changes?\n\nThe dashboard will be temporarily unavailable.",
"Restart Allstarr to reload /app/.env and apply configuration changes?\n\nThe dashboard will be temporarily unavailable.",
)
) {
return;
@@ -291,7 +291,7 @@ async function restartContainer() {
const result = await runAction({
task: () => API.restartContainer(),
error: "Failed to restart container",
error: "Failed to restart Allstarr",
});
if (!result) {
@@ -301,7 +301,7 @@ async function restartContainer() {
document.getElementById("restart-overlay")?.classList.add("active");
const statusEl = document.getElementById("restart-status");
if (statusEl) {
statusEl.textContent = "Stopping container...";
statusEl.textContent = "Restarting Allstarr...";
}
setTimeout(() => {
+104
View File
@@ -783,6 +783,110 @@ export function updateEndpointUsageUI(data) {
.join("");
}
export function updateSquidWtfEndpointHealthUI(data) {
const tbody = document.getElementById("squidwtf-endpoints-table-body");
const testedAt = document.getElementById("squidwtf-endpoints-tested-at");
const endpoints = data.Endpoints || data.endpoints || [];
const testedAtValue = data.TestedAtUtc || data.testedAtUtc;
if (testedAt) {
testedAt.textContent = testedAtValue
? `Last tested ${new Date(testedAtValue).toLocaleString()}`
: "Not tested yet";
}
if (!tbody) {
return;
}
if (endpoints.length === 0) {
tbody.innerHTML =
'<tr><td colspan="3" style="text-align:center;color:var(--text-secondary);padding:40px;">No SquidWTF endpoints configured.</td></tr>';
return;
}
tbody.innerHTML = endpoints
.map((row) => {
const apiResult = normalizeProbeResult(row.Api || row.api);
const streamingResult = normalizeProbeResult(row.Streaming || row.streaming);
const host = row.Host || row.host || "-";
return `
<tr>
<td>
<strong>${escapeHtml(host)}</strong>
</td>
<td style="text-align:center;">
${renderProbeDot(apiResult, "api")}
</td>
<td style="text-align:center;">
${renderProbeDot(streamingResult, "streaming")}
</td>
</tr>
`;
})
.join("");
}
function normalizeProbeResult(result) {
if (!result) {
return {
configured: false,
isUp: false,
state: "unknown",
statusCode: null,
latencyMs: null,
requestUrl: null,
error: null,
};
}
return {
configured: result.Configured ?? result.configured ?? false,
isUp: result.IsUp ?? result.isUp ?? false,
state: result.State ?? result.state ?? "unknown",
statusCode: result.StatusCode ?? result.statusCode ?? null,
latencyMs: result.LatencyMs ?? result.latencyMs ?? null,
requestUrl: result.RequestUrl ?? result.requestUrl ?? null,
error: result.Error ?? result.error ?? null,
};
}
function renderProbeDot(result, type) {
const state = result.state || "unknown";
const isUp = result.isUp === true;
const variant = isUp
? type
: state === "missing"
? "unknown"
: "down";
const titleParts = [];
if (type === "api") {
titleParts.push(isUp ? "API up" : "API down");
} else {
titleParts.push(isUp ? "Streaming up" : "Streaming down");
}
if (result.statusCode != null) {
titleParts.push(`HTTP ${result.statusCode}`);
}
if (result.latencyMs != null) {
titleParts.push(`${result.latencyMs}ms`);
}
if (result.error) {
titleParts.push(result.error);
}
if (result.requestUrl) {
titleParts.push(result.requestUrl);
}
return `<span class="endpoint-health-dot ${variant}" title="${escapeHtml(titleParts.join(" • "))}"></span>`;
}
export function showErrorState(message) {
const statusBadge = document.getElementById("spotify-status");
if (statusBadge) {
+30
View File
@@ -41,6 +41,26 @@
padding: 20px;
}
.support-footer {
max-width: 1400px;
margin: 0 auto 24px;
padding: 20px;
border-top: 1px solid var(--border);
color: var(--text-secondary);
font-size: 0.92rem;
text-align: center;
}
.support-footer a {
color: var(--accent);
text-decoration: none;
}
.support-footer a:hover {
color: var(--accent-hover);
text-decoration: underline;
}
header {
display: flex;
justify-content: space-between;
@@ -646,5 +666,15 @@
</div>
</div>
</div>
<footer class="support-footer">
<p>
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
supporting its development via
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
or
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
</p>
</footer>
</body>
</html>
+130
View File
@@ -69,12 +69,127 @@ body {
font-size: 0.85rem;
}
.support-badge {
position: fixed;
right: 20px;
bottom: 20px;
width: min(360px, calc(100vw - 32px));
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: rgba(22, 27, 34, 0.94);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.28);
color: var(--text-secondary);
font-size: 0.82rem;
line-height: 1.45;
z-index: 10;
}
.support-badge a,
.support-footer a {
color: var(--accent);
text-decoration: none;
}
.support-badge a:hover,
.support-footer a:hover {
color: var(--accent-hover);
text-decoration: underline;
}
.endpoint-health-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.endpoint-health-legend {
display: flex;
gap: 14px;
flex-wrap: wrap;
color: var(--text-secondary);
font-size: 0.85rem;
}
.endpoint-health-legend span {
display: inline-flex;
align-items: center;
gap: 8px;
}
.endpoint-health-last-tested {
color: var(--text-secondary);
font-size: 0.85rem;
}
.endpoint-health-dot {
width: 12px;
height: 12px;
display: inline-block;
border-radius: 999px;
background: var(--text-secondary);
box-shadow: inset 0 0 0 1px rgba(13, 17, 23, 0.25);
}
.endpoint-health-dot.api,
.endpoint-health-dot.up.api {
background: var(--success);
}
.endpoint-health-dot.streaming,
.endpoint-health-dot.up.streaming {
background: var(--accent);
}
.endpoint-health-dot.down {
background: var(--error);
}
.endpoint-health-dot.unknown {
background: var(--text-secondary);
}
.endpoint-health-table td,
.endpoint-health-table th {
vertical-align: middle;
}
.endpoint-link-cell {
max-width: 260px;
}
.endpoint-url {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: monospace;
font-size: 0.82rem;
}
.endpoint-url.muted {
color: var(--text-secondary);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.support-footer {
margin-top: 8px;
padding: 20px 0 8px;
border-top: 1px solid var(--border);
color: var(--text-secondary);
font-size: 0.92rem;
text-align: center;
}
header {
display: flex;
justify-content: space-between;
@@ -859,6 +974,21 @@ input::placeholder {
border-bottom-color: var(--accent);
}
@media (max-width: 768px) {
.support-badge {
right: 12px;
bottom: 12px;
width: min(340px, calc(100vw - 24px));
padding: 10px 12px;
font-size: 0.78rem;
}
.support-footer {
padding-top: 16px;
font-size: 0.88rem;
}
}
.tab-content {
display: none;
}