diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 129ece4..3e2e6b2 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -10,6 +10,6 @@ liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry polar: # Replace with a single Polar username -buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +buy_me_a_coffee: treeman183 thanks_dev: # Replace with a single thanks.dev username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/allstarr.Tests/ImageConditionalRequestHelperTests.cs b/allstarr.Tests/ImageConditionalRequestHelperTests.cs new file mode 100644 index 0000000..6811457 --- /dev/null +++ b/allstarr.Tests/ImageConditionalRequestHelperTests.cs @@ -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\"")); + } +} diff --git a/allstarr.Tests/JellyfinControllerSearchLimitTests.cs b/allstarr.Tests/JellyfinControllerSearchLimitTests.cs new file mode 100644 index 0000000..68c4f18 --- /dev/null +++ b/allstarr.Tests/JellyfinControllerSearchLimitTests.cs @@ -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); + } +} diff --git a/allstarr.Tests/JellyfinImageTagExtractionTests.cs b/allstarr.Tests/JellyfinImageTagExtractionTests.cs new file mode 100644 index 0000000..8001154 --- /dev/null +++ b/allstarr.Tests/JellyfinImageTagExtractionTests.cs @@ -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 }); + } +} diff --git a/allstarr.Tests/JellyfinPlaylistRouteMatchingTests.cs b/allstarr.Tests/JellyfinPlaylistRouteMatchingTests.cs new file mode 100644 index 0000000..0afd647 --- /dev/null +++ b/allstarr.Tests/JellyfinPlaylistRouteMatchingTests.cs @@ -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("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("GetExactPlaylistItemsRequestId", path); + + Assert.Null(playlistId); + } + + private static T InvokePrivateStatic(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!; + } +} diff --git a/allstarr.Tests/JellyfinProxyServiceTests.cs b/allstarr.Tests/JellyfinProxyServiceTests.cs index 7c24a4d..ddb95ab 100644 --- a/allstarr.Tests/JellyfinProxyServiceTests.cs +++ b/allstarr.Tests/JellyfinProxyServiceTests.cs @@ -311,6 +311,169 @@ public class JellyfinProxyServiceTests Assert.Contains("UserId=user-abc", query); } + [Fact] + public async Task GetPassthroughResponseAsync_WithRepeatedFields_PreservesAllFieldParameters() + { + // Arrange + HttpRequestMessage? captured = null; + _mockHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((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>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((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>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((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>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((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>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((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() { diff --git a/allstarr.Tests/JellyfinResponseBuilderTests.cs b/allstarr.Tests/JellyfinResponseBuilderTests.cs index 2cb1d98..baf2cf7 100644 --- a/allstarr.Tests/JellyfinResponseBuilderTests.cs +++ b/allstarr.Tests/JellyfinResponseBuilderTests.cs @@ -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] diff --git a/allstarr.Tests/JellyfinSearchInterleaveTests.cs b/allstarr.Tests/JellyfinSearchInterleaveTests.cs new file mode 100644 index 0000000..7a62cd4 --- /dev/null +++ b/allstarr.Tests/JellyfinSearchInterleaveTests.cs @@ -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> + { + 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> + { + 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> + { + CreateItem("luther remastered"), + CreateItem("zzz filler") + }; + var secondary = new List> + { + 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> + { + CreateItem("bts", "p1"), + CreateItem("bts", "p2") + }; + var secondary = new List> + { + 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> + { + CreateItem("zzz filler", "p1"), + CreateItem("bts local later", "p2") + }; + var secondary = new List> + { + 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> + { + CreateItem("luther remastered", "p1") + }; + var secondary = new List> + { + 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> InvokeInterleaveByScore( + JellyfinController controller, + List> primary, + List> secondary, + string query, + double primaryBoost) + { + var method = typeof(JellyfinController).GetMethod( + "InterleaveByScore", + BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.NotNull(method); + + return (List>)method!.Invoke( + controller, + [primary, secondary, query, primaryBoost])!; + } + + private static double InvokeCalculateItemRelevanceScore( + JellyfinController controller, + string query, + Dictionary item) + { + var method = typeof(JellyfinController).GetMethod( + "CalculateItemRelevanceScore", + BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.NotNull(method); + + return (double)method!.Invoke(controller, [query, item])!; + } + + private static Dictionary CreateItem(string name, string? id = null) + { + return new Dictionary + { + ["Name"] = name, + ["Id"] = id ?? name + }; + } + + private static Dictionary CreateTypedItem(string type, string name, string id) + { + var item = CreateItem(name, id); + item["Type"] = type; + return item; + } + + private static string GetName(Dictionary item) + { + return item["Name"]?.ToString() ?? string.Empty; + } + + private static string GetId(Dictionary item) + { + return item["Id"]?.ToString() ?? string.Empty; + } +} diff --git a/allstarr.Tests/JellyfinSearchResponseSerializationTests.cs b/allstarr.Tests/JellyfinSearchResponseSerializationTests.cs new file mode 100644 index 0000000..db6e347 --- /dev/null +++ b/allstarr.Tests/JellyfinSearchResponseSerializationTests.cs @@ -0,0 +1,38 @@ +using System.Reflection; +using allstarr.Controllers; + +namespace allstarr.Tests; + +public class JellyfinSearchResponseSerializationTests +{ + [Fact] + public void SerializeSearchResponseJson_PreservesPascalCaseShape() + { + var payload = new + { + Items = new[] + { + new Dictionary + { + ["Name"] = "BTS", + ["Type"] = "MusicAlbum" + } + }, + TotalRecordCount = 1, + StartIndex = 0 + }; + + var method = typeof(JellyfinController).GetMethod( + "SerializeSearchResponseJson", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(method); + + var closedMethod = method!.MakeGenericMethod(payload.GetType()); + var json = (string)closedMethod.Invoke(null, new object?[] { payload })!; + + Assert.Equal( + "{\"Items\":[{\"Name\":\"BTS\",\"Type\":\"MusicAlbum\"}],\"TotalRecordCount\":1,\"StartIndex\":0}", + json); + } +} diff --git a/allstarr.Tests/JellyfinSessionManagerTests.cs b/allstarr.Tests/JellyfinSessionManagerTests.cs index 0f4a9b3..dff736b 100644 --- a/allstarr.Tests/JellyfinSessionManagerTests.cs +++ b/allstarr.Tests/JellyfinSessionManagerTests.cs @@ -132,6 +132,46 @@ public class JellyfinSessionManagerTests Assert.Equal(45 * TimeSpan.TicksPerSecond, state.PositionTicks); } + [Fact] + public async Task EnsureSessionAsync_WithProxiedWebSocket_DoesNotPostSyntheticCapabilities() + { + var requestedPaths = new ConcurrentBag(); + 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.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); diff --git a/allstarr.Tests/SpotifyApiClientTests.cs b/allstarr.Tests/SpotifyApiClientTests.cs index 3454b93..bacdb64 100644 --- a/allstarr.Tests/SpotifyApiClientTests.cs +++ b/allstarr.Tests/SpotifyApiClientTests.cs @@ -157,6 +157,31 @@ public class SpotifyApiClientTests Assert.Equal(new DateTime(2026, 2, 16, 5, 0, 0, DateTimeKind.Utc), track.AddedAt); } + [Fact] + public void TryGetSpotifyPlaylistItemCount_ParsesAttributesArrayEntries() + { + // Arrange + using var doc = JsonDocument.Parse(""" + { + "attributes": [ + { "key": "core:item_count", "value": "42" } + ] + } + """); + + var method = typeof(SpotifyApiClient).GetMethod( + "TryGetSpotifyPlaylistItemCount", + BindingFlags.Static | BindingFlags.NonPublic); + + Assert.NotNull(method); + + // Act + var result = (int)method!.Invoke(null, new object?[] { doc.RootElement })!; + + // Assert + Assert.Equal(42, result); + } + private static T InvokePrivateMethod(object instance, string methodName, params object?[] args) { var method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic); diff --git a/allstarr.Tests/SquidWTFMetadataServiceTests.cs b/allstarr.Tests/SquidWTFMetadataServiceTests.cs index f2ac987..34af69b 100644 --- a/allstarr.Tests/SquidWTFMetadataServiceTests.cs +++ b/allstarr.Tests/SquidWTFMetadataServiceTests.cs @@ -299,6 +299,65 @@ public class SquidWTFMetadataServiceTests Assert.NotNull(result); } + [Fact] + public async Task SearchAllAsync_WithZeroLimits_SkipsUnusedBuckets() + { + var requestKinds = new List(); + 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())).Returns(httpClient); + + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + new List { "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() { diff --git a/allstarr/AppVersion.cs b/allstarr/AppVersion.cs index ed18df0..978aff2 100644 --- a/allstarr/AppVersion.cs +++ b/allstarr/AppVersion.cs @@ -9,5 +9,5 @@ public static class AppVersion /// /// Current application version. /// - public const string Version = "1.4.3"; + public const string Version = "1.5.3"; } diff --git a/allstarr/Controllers/DownloadsController.cs b/allstarr/Controllers/DownloadsController.cs index 412dc7c..5a2b7cf 100644 --- a/allstarr/Controllers/DownloadsController.cs +++ b/allstarr/Controllers/DownloadsController.cs @@ -139,6 +139,56 @@ public class DownloadsController : ControllerBase } } + /// + /// DELETE /api/admin/downloads/all + /// Deletes all kept audio files and removes empty folders + /// + [HttpDelete("downloads/all")] + public IActionResult DeleteAllDownloads() + { + try + { + var keptPath = Path.GetFullPath(Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept")); + if (!Directory.Exists(keptPath)) + { + return Ok(new { success = true, deletedCount = 0, message = "No kept downloads found" }); + } + + var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" }; + var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories) + .Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant())) + .ToList(); + + foreach (var filePath in allFiles) + { + System.IO.File.Delete(filePath); + } + + // Clean up empty directories under kept root (deepest first) + var allDirectories = Directory.GetDirectories(keptPath, "*", SearchOption.AllDirectories) + .OrderByDescending(d => d.Length); + foreach (var directory in allDirectories) + { + if (!Directory.EnumerateFileSystemEntries(directory).Any()) + { + Directory.Delete(directory); + } + } + + return Ok(new + { + success = true, + deletedCount = allFiles.Count, + message = $"Deleted {allFiles.Count} kept download(s)" + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete all kept downloads"); + return StatusCode(500, new { error = "Failed to delete all kept downloads" }); + } + } + /// /// GET /api/admin/downloads/file /// Downloads a specific file from the kept folder diff --git a/allstarr/Controllers/Helpers.cs b/allstarr/Controllers/Helpers.cs index 06f1c27..16c7d3f 100644 --- a/allstarr/Controllers/Helpers.cs +++ b/allstarr/Controllers/Helpers.cs @@ -1,9 +1,11 @@ using System.Text.Json; using System.Text; +using System.Net.Http; using allstarr.Models.Domain; using allstarr.Models.Spotify; using allstarr.Services.Common; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Http.Features; namespace allstarr.Controllers; @@ -11,6 +13,20 @@ public partial class JellyfinController { #region Helpers + private static readonly HashSet PassthroughResponseHeadersToSkip = new(StringComparer.OrdinalIgnoreCase) + { + "Connection", + "Keep-Alive", + "Proxy-Authenticate", + "Proxy-Authorization", + "TE", + "Trailer", + "Transfer-Encoding", + "Upgrade", + "Content-Type", + "Content-Length" + }; + /// /// Helper to handle proxy responses with proper status code handling. /// @@ -48,6 +64,60 @@ public partial class JellyfinController return NoContent(); } + private async Task 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()?.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(); + } + } + } + /// /// Updates ChildCount for Spotify playlists in the response to show total tracks (local + matched). /// @@ -407,6 +477,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; + } + /// /// 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 diff --git a/allstarr/Controllers/JellyfinAdminController.cs b/allstarr/Controllers/JellyfinAdminController.cs index eed8edd..a81dfa7 100644 --- a/allstarr/Controllers/JellyfinAdminController.cs +++ b/allstarr/Controllers/JellyfinAdminController.cs @@ -245,7 +245,9 @@ public class JellyfinAdminController : ControllerBase /// Get all playlists from the user's Spotify account /// [HttpGet("jellyfin/playlists")] - public async Task GetJellyfinPlaylists([FromQuery] string? userId = null) + public async Task GetJellyfinPlaylists( + [FromQuery] string? userId = null, + [FromQuery] bool includeStats = true) { if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey)) { @@ -330,13 +332,13 @@ public class JellyfinAdminController : ControllerBase var statsUserId = requestedUserId; var trackStats = (LocalTracks: 0, ExternalTracks: 0, ExternalAvailable: 0); - if (isConfigured) + if (isConfigured && includeStats) { trackStats = await GetPlaylistTrackStats(id!, session, statsUserId); } var actualTrackCount = isConfigured - ? trackStats.LocalTracks + trackStats.ExternalTracks + ? (includeStats ? trackStats.LocalTracks + trackStats.ExternalTracks : childCount) : childCount; playlists.Add(new @@ -349,6 +351,7 @@ public class JellyfinAdminController : ControllerBase isLinkedByAnotherUser, linkedOwnerUserId = scopedLinkedPlaylist?.UserId ?? allLinkedForPlaylist.FirstOrDefault()?.UserId, + statsPending = isConfigured && !includeStats, localTracks = trackStats.LocalTracks, externalTracks = trackStats.ExternalTracks, externalAvailable = trackStats.ExternalAvailable diff --git a/allstarr/Controllers/JellyfinController.Authentication.cs b/allstarr/Controllers/JellyfinController.Authentication.cs index 3169464..ec34d81 100644 --- a/allstarr/Controllers/JellyfinController.Authentication.cs +++ b/allstarr/Controllers/JellyfinController.Authentication.cs @@ -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(), - 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 { diff --git a/allstarr/Controllers/JellyfinController.PlaybackSessions.cs b/allstarr/Controllers/JellyfinController.PlaybackSessions.cs index 853bb48..3129746 100644 --- a/allstarr/Controllers/JellyfinController.PlaybackSessions.cs +++ b/allstarr/Controllers/JellyfinController.PlaybackSessions.cs @@ -1558,9 +1558,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 +1582,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) }; diff --git a/allstarr/Controllers/JellyfinController.Search.cs b/allstarr/Controllers/JellyfinController.Search.cs index ab5be2c..7136cd4 100644 --- a/allstarr/Controllers/JellyfinController.Search.cs +++ b/allstarr/Controllers/JellyfinController.Search.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text; +using allstarr.Models.Domain; using allstarr.Models.Search; using allstarr.Models.Subsonic; using allstarr.Services.Common; @@ -32,6 +33,7 @@ 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; @@ -181,7 +183,7 @@ public partial class JellyfinController // 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,12 +194,12 @@ public partial class JellyfinController recursive, userId, Request.Query["IsFavorite"].ToString()); - var cachedResult = await _cache.GetAsync(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"); } } @@ -303,6 +305,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 +314,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()) @@ -384,11 +404,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 +457,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}", @@ -538,24 +553,16 @@ public partial class JellyfinController TotalRecordCount = items.Count, StartIndex = startIndex }; + var json = SerializeSearchResponseJson(response); // Cache search results in Redis using the configured search TTL. - if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(effectiveArtistIds)) + if (!string.IsNullOrWhiteSpace(searchTerm) && + string.IsNullOrWhiteSpace(effectiveArtistIds) && + !string.IsNullOrWhiteSpace(searchCacheKey)) { if (externalHasRequestedTypeResults) { - var cacheKey = CacheKeyBuilder.BuildSearchKey( - searchTerm, - includeItemTypes, - limit, - startIndex, - parentId, - sortBy, - Request.Query["SortOrder"].ToString(), - recursive, - userId, - Request.Query["IsFavorite"].ToString()); - await _cache.SetAsync(cacheKey, response, CacheExtensions.SearchResultsTTL); + await _cache.SetStringAsync(searchCacheKey, json, CacheExtensions.SearchResultsTTL); _logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm, CacheExtensions.SearchResultsTTL.TotalMinutes); } @@ -570,12 +577,6 @@ public partial class JellyfinController _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; @@ -591,6 +592,15 @@ public partial class JellyfinController } } + private static string SerializeSearchResponseJson(T response) where T : class + { + return JsonSerializer.Serialize(response, new JsonSerializerOptions + { + PropertyNamingPolicy = null, + DictionaryKeyPolicy = null + }); + } + /// /// Gets child items of a parent (tracks in album, albums for artist). /// @@ -681,11 +691,36 @@ 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 +733,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(); + var allAlbums = includesAlbums + ? localAlbums.Concat(externalResult.Albums).Take(limit).ToList() + : new List(); + var allArtists = includesArtists + ? localArtists.Concat(externalResult.Artists).Take(limit).ToList() + : new List(); return _responseBuilder.CreateSearchHintsResponse( allSongs.Take(limit).ToList(), @@ -751,6 +792,33 @@ 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 @@ -761,227 +829,45 @@ public partial class JellyfinController }); } - private List> ApplyRequestedAlbumOrderingIfApplicable( - List> items, - string[]? requestedTypes, - string? sortBy, - string? sortOrder) - { - if (items.Count <= 1 || string.IsNullOrWhiteSpace(sortBy)) - { - return items; - } - - if (requestedTypes == null || requestedTypes.Length == 0) - { - return items; - } - - var isAlbumOnlyRequest = requestedTypes.All(type => - string.Equals(type, "MusicAlbum", StringComparison.OrdinalIgnoreCase) || - string.Equals(type, "Playlist", StringComparison.OrdinalIgnoreCase)); - - if (!isAlbumOnlyRequest) - { - return items; - } - - var sortFields = sortBy - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Where(field => !string.IsNullOrWhiteSpace(field)) - .ToList(); - - if (sortFields.Count == 0) - { - return items; - } - - var descending = string.Equals(sortOrder, "Descending", StringComparison.OrdinalIgnoreCase); - var sorted = items.ToList(); - sorted.Sort((left, right) => CompareAlbumItemsByRequestedSort(left, right, sortFields, descending)); - return sorted; - } - - private int CompareAlbumItemsByRequestedSort( - Dictionary left, - Dictionary right, - IReadOnlyList sortFields, - bool descending) - { - foreach (var field in sortFields) - { - var comparison = CompareAlbumItemsByField(left, right, field); - if (comparison == 0) - { - continue; - } - - return descending ? -comparison : comparison; - } - - return string.Compare(GetItemStringValue(left, "Name"), GetItemStringValue(right, "Name"), StringComparison.OrdinalIgnoreCase); - } - - private int CompareAlbumItemsByField(Dictionary left, Dictionary right, string field) - { - return field.ToLowerInvariant() switch - { - "sortname" => string.Compare(GetItemStringValue(left, "SortName"), GetItemStringValue(right, "SortName"), StringComparison.OrdinalIgnoreCase), - "name" => string.Compare(GetItemStringValue(left, "Name"), GetItemStringValue(right, "Name"), StringComparison.OrdinalIgnoreCase), - "datecreated" => DateTime.Compare(GetItemDateValue(left, "DateCreated"), GetItemDateValue(right, "DateCreated")), - "premieredate" => DateTime.Compare(GetItemDateValue(left, "PremiereDate"), GetItemDateValue(right, "PremiereDate")), - "productionyear" => CompareIntValues(GetItemIntValue(left, "ProductionYear"), GetItemIntValue(right, "ProductionYear")), - _ => 0 - }; - } - - private static int CompareIntValues(int? left, int? right) - { - if (left.HasValue && right.HasValue) - { - return left.Value.CompareTo(right.Value); - } - - if (left.HasValue) - { - return 1; - } - - if (right.HasValue) - { - return -1; - } - - return 0; - } - - private static DateTime GetItemDateValue(Dictionary item, string key) - { - if (!item.TryGetValue(key, out var value) || value == null) - { - return DateTime.MinValue; - } - - if (value is JsonElement jsonElement) - { - if (jsonElement.ValueKind == JsonValueKind.String && - DateTime.TryParse(jsonElement.GetString(), out var parsedDate)) - { - return parsedDate; - } - - return DateTime.MinValue; - } - - if (DateTime.TryParse(value.ToString(), out var parsed)) - { - return parsed; - } - - return DateTime.MinValue; - } - - private static int? GetItemIntValue(Dictionary item, string key) - { - if (!item.TryGetValue(key, out var value) || value == null) - { - return null; - } - - if (value is JsonElement jsonElement) - { - if (jsonElement.ValueKind == JsonValueKind.Number && jsonElement.TryGetInt32(out var intValue)) - { - return intValue; - } - - if (jsonElement.ValueKind == JsonValueKind.String && - int.TryParse(jsonElement.GetString(), out var parsedInt)) - { - return parsedInt; - } - - return null; - } - - return int.TryParse(value.ToString(), out var parsed) ? parsed : null; - } - /// - /// 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. /// private List> InterleaveByScore( List> primaryItems, List> 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>(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 +877,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; } /// - /// 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. /// private double CalculateItemRelevanceScore(string query, Dictionary 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 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 TokenizeForCoverage(string text) - { - if (string.IsNullOrWhiteSpace(text)) - { - return Array.Empty(); - } - - var normalized = NormalizeForCoverage(text); - var allTokens = normalized - .Split(' ', StringSplitOptions.RemoveEmptyEntries) - .Distinct(StringComparer.Ordinal) - .ToList(); - - if (allTokens.Count == 0) - { - return Array.Empty(); - } - - 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(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) + }; } /// @@ -1141,52 +912,90 @@ public partial class JellyfinController return GetItemStringValue(item, "Name"); } - private string BuildItemSearchText(Dictionary item, string title) + private double CalculateSongRelevanceScore(string query, Dictionary item) { - var parts = new List(); - - 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); - } - - return string.Join(" ", parts); + var title = GetItemName(item); + var artistText = GetSongArtistText(item); + return CalculateBestFuzzyScore(query, title, CombineSearchFields(title, artistText)); } - private static readonly HashSet SearchStopWords = new(StringComparer.Ordinal) + private double CalculateAlbumRelevanceScore(string query, Dictionary item) { - "a", - "an", - "and", - "at", - "for", - "in", - "of", - "on", - "the", - "to", - "with", - "feat", - "ft" - }; + var albumName = GetItemName(item); + var artistText = GetAlbumArtistText(item); + return CalculateBestFuzzyScore(query, albumName, CombineSearchFields(albumName, artistText)); + } - private static void AddDistinct(List values, string? value) + private double CalculateArtistRelevanceScore(string query, Dictionary item) { - if (string.IsNullOrWhiteSpace(value)) + var artistName = GetItemName(item); + if (string.IsNullOrWhiteSpace(artistName)) { - return; + return 0; } - if (!values.Contains(value, StringComparer.OrdinalIgnoreCase)) + return FuzzyMatcher.CalculateSimilarityAggressive(query, artistName); + } + + private double CalculateBestFuzzyScore(string query, params string?[] candidates) + { + var best = 0; + + foreach (var candidate in candidates) { - values.Add(value); + 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 item) + { + return GetItemStringValue(item, "Type"); + } + + private string GetSongArtistText(Dictionary 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 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 item, string key) diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index 603deea..7e2596b 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -628,13 +628,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 +678,7 @@ public partial class JellyfinController : ControllerBase if (fallbackBytes != null && fallbackContentType != null) { - return File(fallbackBytes, fallbackContentType); + return CreateConditionalImageResponse(fallbackBytes, fallbackContentType); } } } @@ -680,7 +687,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 +696,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 +767,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 +789,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 +797,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 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 +1346,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.) diff --git a/allstarr/Middleware/WebSocketProxyMiddleware.cs b/allstarr/Middleware/WebSocketProxyMiddleware.cs index 3868c6e..e620f79 100644 --- a/allstarr/Middleware/WebSocketProxyMiddleware.cs +++ b/allstarr/Middleware/WebSocketProxyMiddleware.cs @@ -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) { diff --git a/allstarr/Program.cs b/allstarr/Program.cs index 9a341ee..c53cf49 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -176,6 +176,25 @@ builder.Services.ConfigureAll(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(); @@ -946,7 +965,11 @@ if (app.Environment.IsDevelopment()) app.UseSwaggerUI(); } -app.UseHttpsRedirection(); +// The admin UI is documented and intended to be reachable directly over HTTP on port 5275. +// Keep HTTPS redirection for non-admin traffic only. +app.UseWhen( + context => context.Connection.LocalPort != 5275, + branch => branch.UseHttpsRedirection()); // Serve static files only on admin port (5275) app.UseMiddleware(); diff --git a/allstarr/Services/Common/ImageConditionalRequestHelper.cs b/allstarr/Services/Common/ImageConditionalRequestHelper.cs new file mode 100644 index 0000000..c2c2d7d --- /dev/null +++ b/allstarr/Services/Common/ImageConditionalRequestHelper.cs @@ -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; + } +} diff --git a/allstarr/Services/Common/VersionUpgradeRebuildService.cs b/allstarr/Services/Common/VersionUpgradeRebuildService.cs index 0af392a..b143b25 100644 --- a/allstarr/Services/Common/VersionUpgradeRebuildService.cs +++ b/allstarr/Services/Common/VersionUpgradeRebuildService.cs @@ -14,6 +14,8 @@ public class VersionUpgradeRebuildService : IHostedService private readonly SpotifyTrackMatchingService _matchingService; private readonly SpotifyImportSettings _spotifyImportSettings; private readonly ILogger _logger; + private CancellationTokenSource? _backgroundRebuildCts; + private Task? _backgroundRebuildTask; public VersionUpgradeRebuildService( SpotifyTrackMatchingService matchingService, @@ -53,15 +55,12 @@ public class VersionUpgradeRebuildService : IHostedService } else { - _logger.LogInformation("Triggering full rebuild for all playlists after version upgrade"); - try - { - await _matchingService.TriggerRebuildAllAsync(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to trigger auto rebuild after version upgrade"); - } + _logger.LogInformation( + "Scheduling full rebuild for all playlists in background after version upgrade"); + + _backgroundRebuildCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _backgroundRebuildTask = RunBackgroundRebuildAsync(currentVersion, _backgroundRebuildCts.Token); + return; } } else @@ -76,7 +75,51 @@ public class VersionUpgradeRebuildService : IHostedService public Task StopAsync(CancellationToken cancellationToken) { - return Task.CompletedTask; + return StopBackgroundRebuildAsync(cancellationToken); + } + + private async Task RunBackgroundRebuildAsync(string currentVersion, CancellationToken cancellationToken) + { + try + { + _logger.LogInformation("Starting background full rebuild for all playlists after version upgrade"); + await _matchingService.TriggerRebuildAllAsync(cancellationToken); + _logger.LogInformation("Background full rebuild after version upgrade completed"); + await WriteCurrentVersionAsync(currentVersion, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _logger.LogWarning("Background full rebuild after version upgrade was cancelled before completion"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to trigger auto rebuild after version upgrade"); + await WriteCurrentVersionAsync(currentVersion, CancellationToken.None); + } + } + + private async Task StopBackgroundRebuildAsync(CancellationToken cancellationToken) + { + if (_backgroundRebuildTask == null) + { + return; + } + + try + { + _backgroundRebuildCts?.Cancel(); + await _backgroundRebuildTask.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + // Host shutdown is in progress or the background task observed cancellation. + } + finally + { + _backgroundRebuildCts?.Dispose(); + _backgroundRebuildCts = null; + _backgroundRebuildTask = null; + } } private async Task ReadPreviousVersionAsync(CancellationToken cancellationToken) diff --git a/allstarr/Services/Deezer/DeezerMetadataService.cs b/allstarr/Services/Deezer/DeezerMetadataService.cs index 9768fca..cb2085c 100644 --- a/allstarr/Services/Deezer/DeezerMetadataService.cs +++ b/allstarr/Services/Deezer/DeezerMetadataService.cs @@ -135,10 +135,15 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService public async Task 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()); + var albumsTask = albumLimit > 0 + ? SearchAlbumsAsync(query, albumLimit, cancellationToken) + : Task.FromResult(new List()); + var artistsTask = artistLimit > 0 + ? SearchArtistsAsync(query, artistLimit, cancellationToken) + : Task.FromResult(new List()); await Task.WhenAll(songsTask, albumsTask, artistsTask); diff --git a/allstarr/Services/Jellyfin/JellyfinProxyService.cs b/allstarr/Services/Jellyfin/JellyfinProxyService.cs index 8c80a16..1f46e71 100644 --- a/allstarr/Services/Jellyfin/JellyfinProxyService.cs +++ b/allstarr/Services/Jellyfin/JellyfinProxyService.cs @@ -10,9 +10,17 @@ namespace allstarr.Services.Jellyfin; /// /// Handles proxying requests to the Jellyfin server and authentication. +/// Uses a named HttpClient ("JellyfinBackend") with SocketsHttpHandler for +/// TCP connection pooling across scoped instances. /// public class JellyfinProxyService { + /// + /// The IHttpClientFactory registration name for the Jellyfin backend client. + /// Configured with SocketsHttpHandler for connection pooling in Program.cs. + /// + public const string HttpClientName = "JellyfinBackend"; + private readonly HttpClient _httpClient; private readonly JellyfinSettings _settings; private readonly IHttpContextAccessor _httpContextAccessor; @@ -31,7 +39,7 @@ public class JellyfinProxyService ILogger logger, RedisCacheService cache) { - _httpClient = httpClientFactory.CreateClient(); + _httpClient = httpClientFactory.CreateClient(HttpClientName); _settings = settings.Value; _httpContextAccessor = httpContextAccessor; _logger = logger; @@ -153,62 +161,35 @@ public class JellyfinProxyService return await GetJsonAsyncInternal(finalUrl, clientHeaders); } + /// + /// 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. + /// + public async Task 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); - - // 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; - - // Check if this is a browser request for static assets (favicon, etc.) - bool 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 && - clientHeaders?.Any(h => h.Key.Equals("sec-fetch-dest", StringComparison.OrdinalIgnoreCase) && - (h.Value.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) || - 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) || - url.Contains("/Branding/", StringComparison.OrdinalIgnoreCase) || - url.Contains("/Startup/", StringComparison.OrdinalIgnoreCase); - - // Forward authentication headers from client if provided - if (clientHeaders != null && clientHeaders.Count > 0) - { - authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request); - - if (authHeaderAdded) - { - _logger.LogTrace("Forwarded authentication headers"); - } - - // Check for api_key query parameter (some clients use this) - if (!authHeaderAdded && url.Contains("api_key=", StringComparison.OrdinalIgnoreCase)) - { - authHeaderAdded = true; // It's in the URL, no need to add header - _logger.LogTrace("Using api_key from query string"); - } - } - - // Only log warnings for non-public, non-browser requests without auth - if (!authHeaderAdded && !isBrowserStaticRequest && !isPublicEndpoint) - { - _logger.LogDebug("No client auth provided for {Url} - Jellyfin will handle authentication", url); - } - - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + using var request = CreateClientGetRequest(url, clientHeaders, out var isBrowserStaticRequest, out var isPublicEndpoint); var response = await _httpClient.SendAsync(request); @@ -245,16 +226,13 @@ public class JellyfinProxyService return (JsonDocument.Parse(content), statusCode); } - /// - /// Sends a POST request to the Jellyfin server with JSON body. - /// Forwards client headers for authentication passthrough. - /// Returns the response body and HTTP status code. - /// - public async Task<(JsonDocument? Body, int StatusCode)> PostJsonAsync(string endpoint, string body, IHeaderDictionary clientHeaders) + private HttpRequestMessage CreateClientGetRequest( + string url, + IHeaderDictionary? clientHeaders, + out bool isBrowserStaticRequest, + out bool isPublicEndpoint) { - var url = BuildUrl(endpoint, null); - - using var request = new HttpRequestMessage(HttpMethod.Post, url); + var request = new HttpRequestMessage(HttpMethod.Get, url); // Forward client IP address to Jellyfin so it can identify the real client if (_httpContextAccessor.HttpContext != null) @@ -267,58 +245,177 @@ 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)) + // Check if this is a browser request for static assets (favicon, etc.) + 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 && + clientHeaders?.Any(h => h.Key.Equals("sec-fetch-dest", StringComparison.OrdinalIgnoreCase) && + (h.Value.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) || + h.Value.ToString().Contains("document", StringComparison.OrdinalIgnoreCase))) == true); + + // Check if this is a public endpoint that doesn't require authentication + 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) { - bodyToSend = "{}"; - _logger.LogWarning("POST body was empty for {Url}, sending empty JSON object", url); + authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request); + + if (authHeaderAdded) + { + _logger.LogTrace("Forwarded authentication headers"); + } + + // Check for api_key query parameter (some clients use this) + if (!authHeaderAdded && url.Contains("api_key=", StringComparison.OrdinalIgnoreCase)) + { + authHeaderAdded = true; // It's in the URL, no need to add header + _logger.LogTrace("Using api_key from query string"); + } } - request.Content = new StringContent(bodyToSend, System.Text.Encoding.UTF8, "application/json"); + // Only log warnings for non-public, non-browser requests without auth + if (!authHeaderAdded && !isBrowserStaticRequest && !isPublicEndpoint) + { + _logger.LogDebug("No client auth provided for {Url} - Jellyfin will handle authentication", url); + } - bool authHeaderAdded = false; - bool isAuthEndpoint = endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + return request; + } - // Forward authentication headers from client - authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request); + private static void ForwardPassthroughRequestHeaders( + IHeaderDictionary? clientHeaders, + HttpRequestMessage request) + { + if (clientHeaders == null || clientHeaders.Count == 0) + { + return; + } + + if (clientHeaders.TryGetValue("Accept-Encoding", out var acceptEncoding) && + acceptEncoding.Count > 0) + { + request.Headers.TryAddWithoutValidation("Accept-Encoding", acceptEncoding.ToArray()); + } + + 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()); + } + } + + /// + /// Sends a POST request to the Jellyfin server with JSON body. + /// Forwards client headers for authentication passthrough. + /// Returns the response body and HTTP status code. + /// + 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"); + } + + /// + /// 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. + /// + 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(method, 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); + } + } + + if (body != null) + { + 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 = requestContent; + } + + 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 +432,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 +504,7 @@ public class JellyfinProxyService /// 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); } /// diff --git a/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs b/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs index 16b11ed..a01abb1 100644 --- a/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs +++ b/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs @@ -355,6 +355,7 @@ public class JellyfinResponseBuilder ["Tags"] = new string[0], ["People"] = new object[0], ["SortName"] = songTitle, + ["AudioInfo"] = new Dictionary(), ["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(), ["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(), ["GenreItems"] = new Dictionary[0], ["UserData"] = new Dictionary { @@ -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(), ["GenreItems"] = new Dictionary[0], ["UserData"] = new Dictionary { diff --git a/allstarr/Services/Jellyfin/JellyfinSessionManager.cs b/allstarr/Services/Jellyfin/JellyfinSessionManager.cs index c5ba874..d455383 100644 --- a/allstarr/Services/Jellyfin/JellyfinSessionManager.cs +++ b/allstarr/Services/Jellyfin/JellyfinSessionManager.cs @@ -20,6 +20,7 @@ public class JellyfinSessionManager : IDisposable private readonly ILogger _logger; private readonly ConcurrentDictionary _sessions = new(); private readonly ConcurrentDictionary _sessionInitLocks = new(); + private readonly ConcurrentDictionary _proxiedWebSocketConnections = new(); private readonly Timer _keepAliveTimer; public JellyfinSessionManager( @@ -53,21 +54,28 @@ 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 - var refreshOk = await PostCapabilitiesAsync(headers); - if (!refreshOk) + if (!hasProxiedWebSocket) { - // Token expired - remove the stale session - _logger.LogWarning("Token expired for device {DeviceId} - removing session", deviceId); - await RemoveSessionAsync(deviceId); - return false; + // 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) + { + // Token expired - remove the stale session + _logger.LogWarning("Token expired for device {DeviceId} - removing session", deviceId); + await RemoveSessionAsync(deviceId); + return false; + } } return true; @@ -75,16 +83,26 @@ public class JellyfinSessionManager : IDisposable _logger.LogDebug("Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device); - // Post session capabilities to Jellyfin - this creates the session - var createOk = await PostCapabilitiesAsync(headers); - if (!createOk) + if (!hasProxiedWebSocket) { - // Token expired or invalid - client needs to re-authenticate - _logger.LogError("Failed to create session for {DeviceId} - token may be expired", deviceId); - return false; - } + // 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) + { + // Token expired or invalid - client needs to re-authenticate + _logger.LogError("Failed to create session for {DeviceId} - token may be expired", deviceId); + return false; + } - _logger.LogInformation("Session created for {DeviceId}", deviceId); + _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 +117,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 - _ = Task.Run(() => MaintainWebSocketForSessionAsync(deviceId, headers)); + // 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,6 +141,44 @@ 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); + } + /// /// Posts session capabilities to Jellyfin. /// Returns true if successful, false if token expired (401). @@ -345,8 +406,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 +426,8 @@ public class JellyfinSessionManager : IDisposable /// 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); @@ -422,6 +487,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 +596,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 +713,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 +779,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 +814,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(); + } + } } diff --git a/allstarr/Services/Qobuz/QobuzMetadataService.cs b/allstarr/Services/Qobuz/QobuzMetadataService.cs index 32d4ee2..5ec117d 100644 --- a/allstarr/Services/Qobuz/QobuzMetadataService.cs +++ b/allstarr/Services/Qobuz/QobuzMetadataService.cs @@ -160,9 +160,15 @@ public class QobuzMetadataService : TrackParserBase, IMusicMetadataService public async Task 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()); + var albumsTask = albumLimit > 0 + ? SearchAlbumsAsync(query, albumLimit, cancellationToken) + : Task.FromResult(new List()); + var artistsTask = artistLimit > 0 + ? SearchArtistsAsync(query, artistLimit, cancellationToken) + : Task.FromResult(new List()); await Task.WhenAll(songsTask, albumsTask, artistsTask); diff --git a/allstarr/Services/Spotify/SpotifyApiClient.cs b/allstarr/Services/Spotify/SpotifyApiClient.cs index 4b67b75..2d8b60c 100644 --- a/allstarr/Services/Spotify/SpotifyApiClient.cs +++ b/allstarr/Services/Spotify/SpotifyApiClient.cs @@ -1026,26 +1026,7 @@ public class SpotifyApiClient : IDisposable continue; } - // Get track count if available - try multiple possible paths - var trackCount = 0; - if (playlist.TryGetProperty("content", out var content)) - { - if (content.TryGetProperty("totalCount", out var totalTrackCount)) - { - trackCount = totalTrackCount.GetInt32(); - } - } - // Fallback: try attributes.itemCount - else if (playlist.TryGetProperty("attributes", out var attributes) && - attributes.TryGetProperty("itemCount", out var itemCountProp)) - { - trackCount = itemCountProp.GetInt32(); - } - // Fallback: try totalCount directly - else if (playlist.TryGetProperty("totalCount", out var directTotalCount)) - { - trackCount = directTotalCount.GetInt32(); - } + var trackCount = TryGetSpotifyPlaylistItemCount(playlist); // Log if we couldn't find track count for debugging if (trackCount == 0) @@ -1057,7 +1038,9 @@ public class SpotifyApiClient : IDisposable // Get owner name string? ownerName = null; if (playlist.TryGetProperty("ownerV2", out var ownerV2) && + ownerV2.ValueKind == JsonValueKind.Object && ownerV2.TryGetProperty("data", out var ownerData) && + ownerData.ValueKind == JsonValueKind.Object && ownerData.TryGetProperty("username", out var ownerNameProp)) { ownerName = ownerNameProp.GetString(); @@ -1066,11 +1049,14 @@ public class SpotifyApiClient : IDisposable // Get image URL string? imageUrl = null; if (playlist.TryGetProperty("images", out var images) && + images.ValueKind == JsonValueKind.Object && images.TryGetProperty("items", out var imageItems) && + imageItems.ValueKind == JsonValueKind.Array && imageItems.GetArrayLength() > 0) { var firstImage = imageItems[0]; if (firstImage.TryGetProperty("sources", out var sources) && + sources.ValueKind == JsonValueKind.Array && sources.GetArrayLength() > 0) { var firstSource = sources[0]; @@ -1165,6 +1151,68 @@ public class SpotifyApiClient : IDisposable return null; } + private static int TryGetSpotifyPlaylistItemCount(JsonElement playlistElement) + { + if (playlistElement.TryGetProperty("content", out var content) && + content.ValueKind == JsonValueKind.Object && + content.TryGetProperty("totalCount", out var totalTrackCount) && + TryParseSpotifyIntegerElement(totalTrackCount, out var contentCount)) + { + return contentCount; + } + + if (playlistElement.TryGetProperty("attributes", out var attributes)) + { + if (attributes.ValueKind == JsonValueKind.Object && + attributes.TryGetProperty("itemCount", out var itemCountProp) && + TryParseSpotifyIntegerElement(itemCountProp, out var directAttributeCount)) + { + return directAttributeCount; + } + + if (attributes.ValueKind == JsonValueKind.Array) + { + foreach (var attribute in attributes.EnumerateArray()) + { + if (attribute.ValueKind != JsonValueKind.Object || + !attribute.TryGetProperty("key", out var keyProp) || + keyProp.ValueKind != JsonValueKind.String || + !attribute.TryGetProperty("value", out var valueProp)) + { + continue; + } + + var key = keyProp.GetString(); + if (string.IsNullOrWhiteSpace(key)) + { + continue; + } + + var normalizedKey = key.Replace("_", "", StringComparison.OrdinalIgnoreCase) + .Replace(":", "", StringComparison.OrdinalIgnoreCase); + if (!normalizedKey.Contains("itemcount", StringComparison.OrdinalIgnoreCase) && + !normalizedKey.Contains("trackcount", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (TryParseSpotifyIntegerElement(valueProp, out var attributeCount)) + { + return attributeCount; + } + } + } + } + + if (playlistElement.TryGetProperty("totalCount", out var directTotalCount) && + TryParseSpotifyIntegerElement(directTotalCount, out var totalCount)) + { + return totalCount; + } + + return 0; + } + private static DateTime? ParseSpotifyDateElement(JsonElement value) { switch (value.ValueKind) @@ -1238,6 +1286,40 @@ public class SpotifyApiClient : IDisposable return null; } + private static bool TryParseSpotifyIntegerElement(JsonElement value, out int parsed) + { + switch (value.ValueKind) + { + case JsonValueKind.Number: + return value.TryGetInt32(out parsed); + case JsonValueKind.String: + return int.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed); + case JsonValueKind.Object: + if (value.TryGetProperty("value", out var nestedValue) && + TryParseSpotifyIntegerElement(nestedValue, out parsed)) + { + return true; + } + + if (value.TryGetProperty("itemCount", out var itemCount) && + TryParseSpotifyIntegerElement(itemCount, out parsed)) + { + return true; + } + + if (value.TryGetProperty("totalCount", out var totalCount) && + TryParseSpotifyIntegerElement(totalCount, out parsed)) + { + return true; + } + + break; + } + + parsed = 0; + return false; + } + private static DateTime? ParseSpotifyUnixTimestamp(long value) { try diff --git a/allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs b/allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs index 5e52a96..7942f52 100644 --- a/allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs +++ b/allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs @@ -247,6 +247,7 @@ public class SpotifyPlaylistFetcher : BackgroundService // Re-fetch await GetPlaylistTracksAsync(playlistName); + await ClearPlaylistImageCacheAsync(playlistName); } /// @@ -262,6 +263,20 @@ public class SpotifyPlaylistFetcher : BackgroundService } } + private async Task ClearPlaylistImageCacheAsync(string playlistName) + { + var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName); + 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("========================================"); diff --git a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs index e9a9af6..2e1581e 100644 --- a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs +++ b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs @@ -38,6 +38,7 @@ public class SpotifyTrackMatchingService : BackgroundService private readonly IServiceProvider _serviceProvider; private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count) + private static readonly TimeSpan ExternalProviderSearchTimeout = TimeSpan.FromSeconds(30); // Track last run time per playlist to prevent duplicate runs private readonly Dictionary _lastRunTimes = new(); @@ -295,6 +296,7 @@ public class SpotifyTrackMatchingService : BackgroundService throw; } + await ClearPlaylistImageCacheAsync(playlist); _logger.LogInformation("✓ Rebuild complete for {Playlist}", playlistName); } @@ -337,6 +339,8 @@ public class SpotifyTrackMatchingService : BackgroundService await MatchPlaylistTracksLegacyAsync( playlist.Name, metadataService, cancellationToken); } + + await ClearPlaylistImageCacheAsync(playlist); } catch (Exception ex) { @@ -345,14 +349,27 @@ 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); + } + /// /// 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. /// - public async Task TriggerRebuildAllAsync() + public async Task TriggerRebuildAllAsync(CancellationToken cancellationToken = default) { - _logger.LogInformation("Manual full rebuild triggered for all playlists"); - await RebuildAllPlaylistsAsync(CancellationToken.None); + _logger.LogInformation("Full rebuild triggered for all playlists"); + await RebuildAllPlaylistsAsync(cancellationToken); } /// @@ -757,11 +774,28 @@ public class SpotifyTrackMatchingService : BackgroundService if (cancellationToken.IsCancellationRequested) break; var batch = unmatchedSpotifyTracks.Skip(i).Take(BatchSize).ToList(); + var batchStart = i + 1; + var batchEnd = i + batch.Count; + var batchStopwatch = System.Diagnostics.Stopwatch.StartNew(); + + _logger.LogInformation( + "Starting external matching batch for {Playlist}: tracks {Start}-{End}/{Total}", + playlistName, + batchStart, + batchEnd, + unmatchedSpotifyTracks.Count); var batchTasks = batch.Select(async spotifyTrack => { + var primaryArtist = spotifyTrack.PrimaryArtist; + var trackStopwatch = System.Diagnostics.Stopwatch.StartNew(); + try { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(ExternalProviderSearchTimeout); + var trackCancellationToken = timeoutCts.Token; + var candidates = new List<(Song Song, double Score, string MatchType)>(); // Check global external mapping first @@ -773,12 +807,23 @@ public class SpotifyTrackMatchingService : BackgroundService if (!string.IsNullOrEmpty(globalMapping.ExternalProvider) && !string.IsNullOrEmpty(globalMapping.ExternalId)) { - mappedSong = await metadataService.GetSongAsync(globalMapping.ExternalProvider, globalMapping.ExternalId); + mappedSong = await metadataService.GetSongAsync( + globalMapping.ExternalProvider, + globalMapping.ExternalId, + trackCancellationToken); } if (mappedSong != null) { candidates.Add((mappedSong, 100.0, "global-mapping-external")); + trackStopwatch.Stop(); + _logger.LogDebug( + "External candidate search finished for {Playlist} track #{Position}: {Title} by {Artist} in {ElapsedMs}ms using global mapping", + playlistName, + spotifyTrack.Position, + spotifyTrack.Title, + primaryArtist, + trackStopwatch.ElapsedMilliseconds); return (spotifyTrack, candidates); } } @@ -786,10 +831,31 @@ public class SpotifyTrackMatchingService : BackgroundService // Try ISRC match if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc)) { - var isrcSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService); - if (isrcSong != null) + try { - candidates.Add((isrcSong, 100.0, "isrc")); + var isrcSong = await TryMatchByIsrcAsync( + spotifyTrack.Isrc, + metadataService, + trackCancellationToken); + + if (isrcSong != null) + { + candidates.Add((isrcSong, 100.0, "isrc")); + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "ISRC lookup failed for {Playlist} track #{Position}: {Title} by {Artist}", + playlistName, + spotifyTrack.Position, + spotifyTrack.Title, + primaryArtist); } } @@ -797,7 +863,8 @@ public class SpotifyTrackMatchingService : BackgroundService var fuzzySongs = await TryMatchByFuzzyMultipleAsync( spotifyTrack.Title, spotifyTrack.Artists, - metadataService); + metadataService, + trackCancellationToken); foreach (var (song, score) in fuzzySongs) { @@ -807,16 +874,48 @@ public class SpotifyTrackMatchingService : BackgroundService } } + trackStopwatch.Stop(); + _logger.LogDebug( + "External candidate search finished for {Playlist} track #{Position}: {Title} by {Artist} in {ElapsedMs}ms with {CandidateCount} candidates", + playlistName, + spotifyTrack.Position, + spotifyTrack.Title, + primaryArtist, + trackStopwatch.ElapsedMilliseconds, + candidates.Count); + return (spotifyTrack, candidates); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return (spotifyTrack, new List<(Song, double, string)>()); + } + catch (OperationCanceledException) + { + _logger.LogWarning( + "External candidate search timed out for {Playlist} track #{Position}: {Title} by {Artist} after {TimeoutSeconds}s", + playlistName, + spotifyTrack.Position, + spotifyTrack.Title, + primaryArtist, + ExternalProviderSearchTimeout.TotalSeconds); + return (spotifyTrack, new List<(Song, double, string)>()); + } catch (Exception ex) { - _logger.LogError(ex, "Failed to match track: {Title}", spotifyTrack.Title); + _logger.LogError( + ex, + "Failed to match track for {Playlist} track #{Position}: {Title} by {Artist}", + playlistName, + spotifyTrack.Position, + spotifyTrack.Title, + primaryArtist); return (spotifyTrack, new List<(Song, double, string)>()); } }).ToList(); var batchResults = await Task.WhenAll(batchTasks); + batchStopwatch.Stop(); foreach (var result in batchResults) { @@ -826,6 +925,16 @@ public class SpotifyTrackMatchingService : BackgroundService } } + var batchCandidateCount = batchResults.Sum(result => result.Item2.Count); + _logger.LogInformation( + "Finished external matching batch for {Playlist}: tracks {Start}-{End}/{Total} in {ElapsedMs}ms ({CandidateCount} candidates)", + playlistName, + batchStart, + batchEnd, + unmatchedSpotifyTracks.Count, + batchStopwatch.ElapsedMilliseconds, + batchCandidateCount); + if (i + BatchSize < unmatchedSpotifyTracks.Count) { await Task.Delay(DelayBetweenSearchesMs, cancellationToken); @@ -998,140 +1107,136 @@ public class SpotifyTrackMatchingService : BackgroundService private async Task> TryMatchByFuzzyMultipleAsync( string title, List artists, - IMusicMetadataService metadataService) + IMusicMetadataService metadataService, + CancellationToken cancellationToken) { - try + var primaryArtist = artists.FirstOrDefault() ?? ""; + var titleStripped = FuzzyMatcher.StripDecorators(title); + var query = $"{titleStripped} {primaryArtist}"; + + var allCandidates = new List<(Song Song, double Score)>(); + + // STEP 1: Search LOCAL Jellyfin library FIRST + using var scope = _serviceProvider.CreateScope(); + var proxyService = scope.ServiceProvider.GetService(); + if (proxyService != null) { - var primaryArtist = artists.FirstOrDefault() ?? ""; - var titleStripped = FuzzyMatcher.StripDecorators(title); - var query = $"{titleStripped} {primaryArtist}"; - - var allCandidates = new List<(Song Song, double Score)>(); - - // STEP 1: Search LOCAL Jellyfin library FIRST - using var scope = _serviceProvider.CreateScope(); - var proxyService = scope.ServiceProvider.GetService(); - if (proxyService != null) + try { - try + // Search Jellyfin for local tracks + var searchParams = new Dictionary { - // Search Jellyfin for local tracks - var searchParams = new Dictionary - { - ["searchTerm"] = query, - ["includeItemTypes"] = "Audio", - ["recursive"] = "true", - ["limit"] = "10" - }; + ["searchTerm"] = query, + ["includeItemTypes"] = "Audio", + ["recursive"] = "true", + ["limit"] = "10" + }; - var (searchResponse, _) = await proxyService.GetJsonAsyncInternal("Items", searchParams); + var (searchResponse, _) = await proxyService.GetJsonAsyncInternal("Items", searchParams); - if (searchResponse != null && searchResponse.RootElement.TryGetProperty("Items", out var items)) + if (searchResponse != null && searchResponse.RootElement.TryGetProperty("Items", out var items)) + { + var localResults = new List(); + foreach (var item in items.EnumerateArray()) { - var localResults = new List(); - foreach (var item in items.EnumerateArray()) + var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() ?? "" : ""; + var songTitle = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : ""; + var artist = ""; + + if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) { - var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() ?? "" : ""; - var songTitle = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : ""; - var artist = ""; - - if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) - { - artist = artistsEl[0].GetString() ?? ""; - } - else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl)) - { - artist = albumArtistEl.GetString() ?? ""; - } - - localResults.Add(new Song - { - Id = id, - Title = songTitle, - Artist = artist, - IsLocal = true - }); + artist = artistsEl[0].GetString() ?? ""; + } + else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl)) + { + artist = albumArtistEl.GetString() ?? ""; } - if (localResults.Count > 0) + localResults.Add(new Song { - // Score local results - var scoredLocal = localResults - .Select(song => new - { - Song = song, - TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title), - ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors) - }) - .Select(x => new - { - x.Song, - x.TitleScore, - x.ArtistScore, - TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3) - }) - .Where(x => - x.TotalScore >= 40 || - (x.ArtistScore >= 70 && x.TitleScore >= 30) || - x.TitleScore >= 85) - .OrderByDescending(x => x.TotalScore) - .Select(x => (x.Song, x.TotalScore)) - .ToList(); + Id = id, + Title = songTitle, + Artist = artist, + IsLocal = true + }); + } - allCandidates.AddRange(scoredLocal); - - // If we found good local matches, return them (don't search external) - if (scoredLocal.Any(x => x.TotalScore >= 70)) + if (localResults.Count > 0) + { + // Score local results + var scoredLocal = localResults + .Select(song => new { - _logger.LogDebug("Found {Count} local matches for '{Title}', skipping external search", - scoredLocal.Count, title); - return allCandidates; - } + Song = song, + TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title), + ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors) + }) + .Select(x => new + { + x.Song, + x.TitleScore, + x.ArtistScore, + TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3) + }) + .Where(x => + x.TotalScore >= 40 || + (x.ArtistScore >= 70 && x.TitleScore >= 30) || + x.TitleScore >= 85) + .OrderByDescending(x => x.TotalScore) + .Select(x => (x.Song, x.TotalScore)) + .ToList(); + + allCandidates.AddRange(scoredLocal); + + // If we found good local matches, return them (don't search external) + if (scoredLocal.Any(x => x.TotalScore >= 70)) + { + _logger.LogDebug("Found {Count} local matches for '{Title}', skipping external search", + scoredLocal.Count, title); + return allCandidates; } } } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to search local library for '{Title}'", title); - } } - - // STEP 2: Only search EXTERNAL if no good local match found - var externalResults = await metadataService.SearchSongsAsync(query, limit: 10); - - if (externalResults.Count > 0) + catch (Exception ex) { - var scoredExternal = externalResults - .Select(song => new - { - Song = song, - TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title), - ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors) - }) - .Select(x => new - { - x.Song, - x.TitleScore, - x.ArtistScore, - TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3) - }) - .Where(x => - x.TotalScore >= 40 || - (x.ArtistScore >= 70 && x.TitleScore >= 30) || - x.TitleScore >= 85) - .OrderByDescending(x => x.TotalScore) - .Select(x => (x.Song, x.TotalScore)) - .ToList(); - - allCandidates.AddRange(scoredExternal); + _logger.LogWarning(ex, "Failed to search local library for '{Title}'", title); } + } - return allCandidates; - } - catch + cancellationToken.ThrowIfCancellationRequested(); + + // STEP 2: Only search EXTERNAL if no good local match found + var externalResults = await metadataService.SearchSongsAsync(query, limit: 10, cancellationToken); + + if (externalResults.Count > 0) { - return new List<(Song, double)>(); + var scoredExternal = externalResults + .Select(song => new + { + Song = song, + TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title), + ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors) + }) + .Select(x => new + { + x.Song, + x.TitleScore, + x.ArtistScore, + TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3) + }) + .Where(x => + x.TotalScore >= 40 || + (x.ArtistScore >= 70 && x.TitleScore >= 30) || + x.TitleScore >= 85) + .OrderByDescending(x => x.TotalScore) + .Select(x => (x.Song, x.TotalScore)) + .ToList(); + + allCandidates.AddRange(scoredExternal); } + + return allCandidates; } private double CalculateMatchScore(string jellyfinTitle, string jellyfinArtist, string spotifyTitle, string spotifyArtist) @@ -1145,21 +1250,19 @@ public class SpotifyTrackMatchingService : BackgroundService /// Attempts to match a track by ISRC. /// SEARCHES LOCAL FIRST, then external if no local match found. /// - private async Task TryMatchByIsrcAsync(string isrc, IMusicMetadataService metadataService) + private async Task TryMatchByIsrcAsync( + string isrc, + IMusicMetadataService metadataService, + CancellationToken cancellationToken) { - try - { - // STEP 1: Search LOCAL Jellyfin library FIRST by ISRC - // Note: Jellyfin doesn't have ISRC search, so we skip local ISRC search - // Local tracks will be found via fuzzy matching instead + // STEP 1: Search LOCAL Jellyfin library FIRST by ISRC + // Note: Jellyfin doesn't have ISRC search, so we skip local ISRC search + // Local tracks will be found via fuzzy matching instead - // STEP 2: Search EXTERNAL by ISRC - return await metadataService.FindSongByIsrcAsync(isrc); - } - catch - { - return null; - } + cancellationToken.ThrowIfCancellationRequested(); + + // STEP 2: Search EXTERNAL by ISRC + return await metadataService.FindSongByIsrcAsync(isrc, cancellationToken); } /// diff --git a/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs b/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs index ae9f07f..a98f67c 100644 --- a/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs +++ b/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs @@ -498,14 +498,19 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService public async Task 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()); + var albumsTask = albumLimit > 0 + ? SearchAlbumsAsync(query, albumLimit, cancellationToken) + : Task.FromResult(new List()); + var artistsTask = artistLimit > 0 + ? SearchArtistsAsync(query, artistLimit, cancellationToken) + : Task.FromResult(new List()); await Task.WhenAll(songsTask, albumsTask, artistsTask); - var temp = new SearchResult + var temp = new SearchResult { Songs = await songsTask, Albums = await albumsTask, diff --git a/allstarr/wwwroot/images/buymeacoffee-symbol.svg b/allstarr/wwwroot/images/buymeacoffee-symbol.svg new file mode 100644 index 0000000..c6903c3 --- /dev/null +++ b/allstarr/wwwroot/images/buymeacoffee-symbol.svg @@ -0,0 +1 @@ + diff --git a/allstarr/wwwroot/images/github-mark.svg b/allstarr/wwwroot/images/github-mark.svg new file mode 100644 index 0000000..5edc74d --- /dev/null +++ b/allstarr/wwwroot/images/github-mark.svg @@ -0,0 +1 @@ + diff --git a/allstarr/wwwroot/images/kofi_symbol.svg b/allstarr/wwwroot/images/kofi_symbol.svg new file mode 100644 index 0000000..ade749d --- /dev/null +++ b/allstarr/wwwroot/images/kofi_symbol.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/allstarr/wwwroot/index.html b/allstarr/wwwroot/index.html index b405d6e..83ba574 100644 --- a/allstarr/wwwroot/index.html +++ b/allstarr/wwwroot/index.html @@ -12,8 +12,8 @@
⚠️ Configuration changed. Restart required to apply changes. - - +
@@ -32,46 +32,86 @@ + +
+

+ If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider + supporting its development +

+ +
-