mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-21 02:02:31 -04:00
v1.5.3: feat: completely overhaul search to much better respect the respective search orderings, treats it as fifo, and also entirely transparently proxies Syncplay endpoints and Sessions moreso to allow for syncplay to work
This commit is contained in:
+1
-1
@@ -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']
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using allstarr.Services.Common;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class ImageConditionalRequestHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeStrongETag_SamePayload_ReturnsStableQuotedHash()
|
||||
{
|
||||
var payload = new byte[] { 1, 2, 3, 4 };
|
||||
|
||||
var first = ImageConditionalRequestHelper.ComputeStrongETag(payload);
|
||||
var second = ImageConditionalRequestHelper.ComputeStrongETag(payload);
|
||||
|
||||
Assert.Equal(first, second);
|
||||
Assert.StartsWith("\"", first);
|
||||
Assert.EndsWith("\"", first);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesIfNoneMatch_WithExactMatch_ReturnsTrue()
|
||||
{
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["If-None-Match"] = "\"ABC123\""
|
||||
};
|
||||
|
||||
Assert.True(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"ABC123\""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesIfNoneMatch_WithMultipleValues_ReturnsTrueForMatchingEntry()
|
||||
{
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["If-None-Match"] = "\"stale\", \"fresh\""
|
||||
};
|
||||
|
||||
Assert.True(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"fresh\""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesIfNoneMatch_WithWildcard_ReturnsTrue()
|
||||
{
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["If-None-Match"] = "*"
|
||||
};
|
||||
|
||||
Assert.True(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"anything\""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesIfNoneMatch_WithoutMatch_ReturnsFalse()
|
||||
{
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["If-None-Match"] = "\"ABC123\""
|
||||
};
|
||||
|
||||
Assert.False(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"XYZ789\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Reflection;
|
||||
using allstarr.Controllers;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class JellyfinControllerSearchLimitTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null, 20, true, 20, 20, 20)]
|
||||
[InlineData("MusicAlbum", 20, true, 0, 20, 0)]
|
||||
[InlineData("Audio", 20, true, 20, 0, 0)]
|
||||
[InlineData("MusicArtist", 20, true, 0, 0, 20)]
|
||||
[InlineData("Playlist", 20, true, 0, 20, 0)]
|
||||
[InlineData("Playlist", 20, false, 0, 0, 0)]
|
||||
[InlineData("Audio,MusicArtist", 15, true, 15, 0, 15)]
|
||||
[InlineData("BoxSet", 10, true, 0, 0, 0)]
|
||||
public void GetExternalSearchLimits_UsesRequestedItemTypes(
|
||||
string? includeItemTypes,
|
||||
int limit,
|
||||
bool includePlaylistsAsAlbums,
|
||||
int expectedSongLimit,
|
||||
int expectedAlbumLimit,
|
||||
int expectedArtistLimit)
|
||||
{
|
||||
var requestedTypes = string.IsNullOrWhiteSpace(includeItemTypes)
|
||||
? null
|
||||
: includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
var method = typeof(JellyfinController).GetMethod(
|
||||
"GetExternalSearchLimits",
|
||||
BindingFlags.Static | BindingFlags.NonPublic);
|
||||
|
||||
Assert.NotNull(method);
|
||||
|
||||
var result = ((int SongLimit, int AlbumLimit, int ArtistLimit))method!.Invoke(
|
||||
null,
|
||||
new object?[] { requestedTypes, limit, includePlaylistsAsAlbums })!;
|
||||
|
||||
Assert.Equal(expectedSongLimit, result.SongLimit);
|
||||
Assert.Equal(expectedAlbumLimit, result.AlbumLimit);
|
||||
Assert.Equal(expectedArtistLimit, result.ArtistLimit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using allstarr.Controllers;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class JellyfinImageTagExtractionTests
|
||||
{
|
||||
[Fact]
|
||||
public void ExtractImageTag_WithMatchingImageTagsObject_ReturnsRequestedTag()
|
||||
{
|
||||
using var document = JsonDocument.Parse("""
|
||||
{
|
||||
"ImageTags": {
|
||||
"Primary": "playlist-primary-tag",
|
||||
"Backdrop": "playlist-backdrop-tag"
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var imageTag = InvokeExtractImageTag(document.RootElement, "Primary");
|
||||
|
||||
Assert.Equal("playlist-primary-tag", imageTag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractImageTag_WithPrimaryImageTagFallback_ReturnsFallbackTag()
|
||||
{
|
||||
using var document = JsonDocument.Parse("""
|
||||
{
|
||||
"PrimaryImageTag": "primary-fallback-tag"
|
||||
}
|
||||
""");
|
||||
|
||||
var imageTag = InvokeExtractImageTag(document.RootElement, "Primary");
|
||||
|
||||
Assert.Equal("primary-fallback-tag", imageTag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractImageTag_WithoutMatchingTag_ReturnsNull()
|
||||
{
|
||||
using var document = JsonDocument.Parse("""
|
||||
{
|
||||
"ImageTags": {
|
||||
"Backdrop": "playlist-backdrop-tag"
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var imageTag = InvokeExtractImageTag(document.RootElement, "Primary");
|
||||
|
||||
Assert.Null(imageTag);
|
||||
}
|
||||
|
||||
private static string? InvokeExtractImageTag(JsonElement item, string imageType)
|
||||
{
|
||||
var method = typeof(JellyfinController).GetMethod(
|
||||
"ExtractImageTag",
|
||||
BindingFlags.Static | BindingFlags.NonPublic);
|
||||
|
||||
Assert.NotNull(method);
|
||||
return (string?)method!.Invoke(null, new object?[] { item, imageType });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Reflection;
|
||||
using allstarr.Controllers;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class JellyfinPlaylistRouteMatchingTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("playlists/abc123/items", "abc123")]
|
||||
[InlineData("Playlists/abc123/Items", "abc123")]
|
||||
[InlineData("/playlists/abc123/items/", "abc123")]
|
||||
public void GetExactPlaylistItemsRequestId_ExactPlaylistItemsRoute_ReturnsPlaylistId(string path, string expectedPlaylistId)
|
||||
{
|
||||
var playlistId = InvokePrivateStatic<string?>("GetExactPlaylistItemsRequestId", path);
|
||||
|
||||
Assert.Equal(expectedPlaylistId, playlistId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("playlists/abc123/items/extra")]
|
||||
[InlineData("users/user-1/playlists/abc123/items")]
|
||||
[InlineData("items/abc123")]
|
||||
[InlineData("playlists")]
|
||||
public void GetExactPlaylistItemsRequestId_NonExactRoute_ReturnsNull(string path)
|
||||
{
|
||||
var playlistId = InvokePrivateStatic<string?>("GetExactPlaylistItemsRequestId", path);
|
||||
|
||||
Assert.Null(playlistId);
|
||||
}
|
||||
|
||||
private static T InvokePrivateStatic<T>(string methodName, params object?[] args)
|
||||
{
|
||||
var method = typeof(JellyfinController).GetMethod(
|
||||
methodName,
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
Assert.NotNull(method);
|
||||
var result = method!.Invoke(null, args);
|
||||
return (T)result!;
|
||||
}
|
||||
}
|
||||
@@ -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<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"Items\":[]}")
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await _service.GetPassthroughResponseAsync(
|
||||
"Playlists/playlist-123/Items?Fields=Genres&Fields=DateCreated&Fields=MediaSources&UserId=user-abc");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(captured);
|
||||
var query = captured!.RequestUri!.Query;
|
||||
Assert.Contains("Fields=Genres", query);
|
||||
Assert.Contains("Fields=DateCreated", query);
|
||||
Assert.Contains("Fields=MediaSources", query);
|
||||
Assert.Contains("UserId=user-abc", query);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPassthroughResponseAsync_WithClientAuth_ForwardsAuthHeader()
|
||||
{
|
||||
// Arrange
|
||||
HttpRequestMessage? captured = null;
|
||||
_mockHandler.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"Items\":[]}")
|
||||
});
|
||||
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["X-Emby-Authorization"] = "MediaBrowser Token=\"abc\""
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _service.GetPassthroughResponseAsync(
|
||||
"Playlists/playlist-123/Items?Fields=Genres",
|
||||
headers);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(captured);
|
||||
Assert.True(captured!.Headers.TryGetValues("X-Emby-Authorization", out var values));
|
||||
Assert.Contains("MediaBrowser Token=\"abc\"", values);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_WithNoBody_PreservesEmptyRequestBody()
|
||||
{
|
||||
// Arrange
|
||||
HttpRequestMessage? captured = null;
|
||||
_mockHandler.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) => captured = req)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["X-Emby-Authorization"] = "MediaBrowser Token=\"abc\""
|
||||
};
|
||||
|
||||
// Act
|
||||
var (_, statusCode) = await _service.SendAsync(
|
||||
HttpMethod.Post,
|
||||
"Sessions/session-123/Playing/Pause?controllingUserId=user-123",
|
||||
null,
|
||||
headers);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(204, statusCode);
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(HttpMethod.Post, captured!.Method);
|
||||
Assert.Null(captured.Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_WithCustomContentType_PreservesOriginalType()
|
||||
{
|
||||
// Arrange
|
||||
HttpRequestMessage? captured = null;
|
||||
_mockHandler.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) => captured = req)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["X-Emby-Authorization"] = "MediaBrowser Token=\"abc\""
|
||||
};
|
||||
|
||||
// Act
|
||||
await _service.SendAsync(
|
||||
HttpMethod.Put,
|
||||
"Sessions/session-123/Command/DisplayMessage",
|
||||
"{\"Text\":\"hello\"}",
|
||||
headers,
|
||||
"application/json; charset=utf-8");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(HttpMethod.Put, captured!.Method);
|
||||
Assert.NotNull(captured.Content);
|
||||
Assert.Equal("application/json; charset=utf-8", captured.Content!.Headers.ContentType!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPassthroughResponseAsync_WithAcceptEncoding_ForwardsCompressionHeaders()
|
||||
{
|
||||
// Arrange
|
||||
HttpRequestMessage? captured = null;
|
||||
_mockHandler.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"Items\":[]}")
|
||||
});
|
||||
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["Accept-Encoding"] = "gzip, br",
|
||||
["User-Agent"] = "Finamp/1.0",
|
||||
["Accept-Language"] = "en-US"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _service.GetPassthroughResponseAsync(
|
||||
"Playlists/playlist-123/Items?Fields=Genres",
|
||||
headers);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(captured);
|
||||
Assert.True(captured!.Headers.TryGetValues("Accept-Encoding", out var encodings));
|
||||
Assert.Contains("gzip", encodings);
|
||||
Assert.Contains("br", encodings);
|
||||
Assert.True(captured.Headers.TryGetValues("User-Agent", out var userAgents));
|
||||
Assert.Contains("Finamp/1.0", userAgents);
|
||||
Assert.True(captured.Headers.TryGetValues("Accept-Language", out var languages));
|
||||
Assert.Contains("en-US", languages);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetJsonAsync_WithEndpointAndExplicitQuery_MergesWithExplicitPrecedence()
|
||||
{
|
||||
|
||||
@@ -47,6 +47,8 @@ public class JellyfinResponseBuilderTests
|
||||
Assert.Equal(1, result["ParentIndexNumber"]);
|
||||
Assert.Equal(2023, result["ProductionYear"]);
|
||||
Assert.Equal(245 * TimeSpan.TicksPerSecond, result["RunTimeTicks"]);
|
||||
Assert.NotNull(result["AudioInfo"]);
|
||||
Assert.Equal(false, result["CanDelete"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -192,6 +194,9 @@ public class JellyfinResponseBuilderTests
|
||||
Assert.Equal("Famous Band", result["AlbumArtist"]);
|
||||
Assert.Equal(2020, result["ProductionYear"]);
|
||||
Assert.Equal(12, result["ChildCount"]);
|
||||
Assert.Equal("Greatest Hits", result["SortName"]);
|
||||
Assert.NotNull(result["DateCreated"]);
|
||||
Assert.NotNull(result["BasicSyncInfo"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -215,6 +220,9 @@ public class JellyfinResponseBuilderTests
|
||||
Assert.Equal("MusicArtist", result["Type"]);
|
||||
Assert.Equal(true, result["IsFolder"]);
|
||||
Assert.Equal(5, result["AlbumCount"]);
|
||||
Assert.Equal("The Rockers", result["SortName"]);
|
||||
Assert.Equal(1.0, result["PrimaryImageAspectRatio"]);
|
||||
Assert.NotNull(result["BasicSyncInfo"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -243,6 +251,9 @@ public class JellyfinResponseBuilderTests
|
||||
Assert.Equal("DJ Cool", result["AlbumArtist"]);
|
||||
Assert.Equal(50, result["ChildCount"]);
|
||||
Assert.Equal(2023, result["ProductionYear"]);
|
||||
Assert.Equal("Summer Vibes [S/P]", result["SortName"]);
|
||||
Assert.NotNull(result["DateCreated"]);
|
||||
Assert.NotNull(result["BasicSyncInfo"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using allstarr.Controllers;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class JellyfinSearchInterleaveTests
|
||||
{
|
||||
[Fact]
|
||||
public void InterleaveByScore_PrimaryOnly_PreservesOriginalOrder()
|
||||
{
|
||||
var controller = CreateController();
|
||||
var primary = new List<Dictionary<string, object?>>
|
||||
{
|
||||
CreateItem("zzz filler"),
|
||||
CreateItem("BTS Anthem")
|
||||
};
|
||||
|
||||
var result = InvokeInterleaveByScore(controller, primary, [], "bts", 5.0);
|
||||
|
||||
Assert.Equal(["zzz filler", "BTS Anthem"], result.Select(GetName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InterleaveByScore_SecondaryOnly_PreservesOriginalOrder()
|
||||
{
|
||||
var controller = CreateController();
|
||||
var secondary = new List<Dictionary<string, object?>>
|
||||
{
|
||||
CreateItem("zzz filler"),
|
||||
CreateItem("BTS Anthem")
|
||||
};
|
||||
|
||||
var result = InvokeInterleaveByScore(controller, [], secondary, "bts", 5.0);
|
||||
|
||||
Assert.Equal(["zzz filler", "BTS Anthem"], result.Select(GetName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InterleaveByScore_StrongerHeadMatch_LeadsWithoutReorderingSource()
|
||||
{
|
||||
var controller = CreateController();
|
||||
var primary = new List<Dictionary<string, object?>>
|
||||
{
|
||||
CreateItem("luther remastered"),
|
||||
CreateItem("zzz filler")
|
||||
};
|
||||
var secondary = new List<Dictionary<string, object?>>
|
||||
{
|
||||
CreateItem("luther"),
|
||||
CreateItem("yyy filler")
|
||||
};
|
||||
|
||||
var result = InvokeInterleaveByScore(controller, primary, secondary, "luther", 0.0);
|
||||
|
||||
Assert.Equal(["luther", "luther remastered", "zzz filler", "yyy filler"], result.Select(GetName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InterleaveByScore_TiedScores_PreferPrimaryQueueHead()
|
||||
{
|
||||
var controller = CreateController();
|
||||
var primary = new List<Dictionary<string, object?>>
|
||||
{
|
||||
CreateItem("bts", "p1"),
|
||||
CreateItem("bts", "p2")
|
||||
};
|
||||
var secondary = new List<Dictionary<string, object?>>
|
||||
{
|
||||
CreateItem("bts", "s1"),
|
||||
CreateItem("bts", "s2")
|
||||
};
|
||||
|
||||
var result = InvokeInterleaveByScore(controller, primary, secondary, "bts", 0.0);
|
||||
|
||||
Assert.Equal(["p1", "p2", "s1", "s2"], result.Select(GetId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InterleaveByScore_StrongerLaterPrimaryHead_DoesNotBypassCurrentQueueHead()
|
||||
{
|
||||
var controller = CreateController();
|
||||
var primary = new List<Dictionary<string, object?>>
|
||||
{
|
||||
CreateItem("zzz filler", "p1"),
|
||||
CreateItem("bts local later", "p2")
|
||||
};
|
||||
var secondary = new List<Dictionary<string, object?>>
|
||||
{
|
||||
CreateItem("bts", "s1"),
|
||||
CreateItem("bts live", "s2")
|
||||
};
|
||||
|
||||
var result = InvokeInterleaveByScore(controller, primary, secondary, "bts", 0.0);
|
||||
|
||||
Assert.Equal(["s1", "s2", "p1", "p2"], result.Select(GetId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InterleaveByScore_JellyfinBoost_CanWinCloseHeadToHead()
|
||||
{
|
||||
var controller = CreateController();
|
||||
var primary = new List<Dictionary<string, object?>>
|
||||
{
|
||||
CreateItem("luther remastered", "p1")
|
||||
};
|
||||
var secondary = new List<Dictionary<string, object?>>
|
||||
{
|
||||
CreateItem("luther", "s1")
|
||||
};
|
||||
|
||||
var result = InvokeInterleaveByScore(controller, primary, secondary, "luther", 5.0);
|
||||
|
||||
Assert.Equal(["p1", "s1"], result.Select(GetId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateItemRelevanceScore_SongUsesArtistContext()
|
||||
{
|
||||
var controller = CreateController();
|
||||
var withArtist = CreateTypedItem("Audio", "cardigan", "song-with-artist");
|
||||
withArtist["Artists"] = new[] { "Taylor Swift" };
|
||||
|
||||
var withoutArtist = CreateTypedItem("Audio", "cardigan", "song-without-artist");
|
||||
|
||||
var withArtistScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", withArtist);
|
||||
var withoutArtistScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", withoutArtist);
|
||||
|
||||
Assert.True(withArtistScore > withoutArtistScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateItemRelevanceScore_AlbumUsesArtistContext()
|
||||
{
|
||||
var controller = CreateController();
|
||||
var withArtist = CreateTypedItem("MusicAlbum", "folklore", "album-with-artist");
|
||||
withArtist["AlbumArtist"] = "Taylor Swift";
|
||||
|
||||
var withoutArtist = CreateTypedItem("MusicAlbum", "folklore", "album-without-artist");
|
||||
|
||||
var withArtistScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", withArtist);
|
||||
var withoutArtistScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", withoutArtist);
|
||||
|
||||
Assert.True(withArtistScore > withoutArtistScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateItemRelevanceScore_ArtistIgnoresNonNameMetadata()
|
||||
{
|
||||
var controller = CreateController();
|
||||
var plainArtist = CreateTypedItem("MusicArtist", "Taylor Swift", "artist-plain");
|
||||
var noisyArtist = CreateTypedItem("MusicArtist", "Taylor Swift", "artist-noisy");
|
||||
noisyArtist["AlbumArtist"] = "Completely Different";
|
||||
noisyArtist["Artists"] = new[] { "Someone Else" };
|
||||
|
||||
var plainScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", plainArtist);
|
||||
var noisyScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", noisyArtist);
|
||||
|
||||
Assert.Equal(plainScore, noisyScore);
|
||||
}
|
||||
|
||||
private static JellyfinController CreateController()
|
||||
{
|
||||
return (JellyfinController)RuntimeHelpers.GetUninitializedObject(typeof(JellyfinController));
|
||||
}
|
||||
|
||||
private static List<Dictionary<string, object?>> InvokeInterleaveByScore(
|
||||
JellyfinController controller,
|
||||
List<Dictionary<string, object?>> primary,
|
||||
List<Dictionary<string, object?>> secondary,
|
||||
string query,
|
||||
double primaryBoost)
|
||||
{
|
||||
var method = typeof(JellyfinController).GetMethod(
|
||||
"InterleaveByScore",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
Assert.NotNull(method);
|
||||
|
||||
return (List<Dictionary<string, object?>>)method!.Invoke(
|
||||
controller,
|
||||
[primary, secondary, query, primaryBoost])!;
|
||||
}
|
||||
|
||||
private static double InvokeCalculateItemRelevanceScore(
|
||||
JellyfinController controller,
|
||||
string query,
|
||||
Dictionary<string, object?> item)
|
||||
{
|
||||
var method = typeof(JellyfinController).GetMethod(
|
||||
"CalculateItemRelevanceScore",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
Assert.NotNull(method);
|
||||
|
||||
return (double)method!.Invoke(controller, [query, item])!;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> CreateItem(string name, string? id = null)
|
||||
{
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["Name"] = name,
|
||||
["Id"] = id ?? name
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> CreateTypedItem(string type, string name, string id)
|
||||
{
|
||||
var item = CreateItem(name, id);
|
||||
item["Type"] = type;
|
||||
return item;
|
||||
}
|
||||
|
||||
private static string GetName(Dictionary<string, object?> item)
|
||||
{
|
||||
return item["Name"]?.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string GetId(Dictionary<string, object?> item)
|
||||
{
|
||||
return item["Id"]?.ToString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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<string, object?>
|
||||
{
|
||||
["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);
|
||||
}
|
||||
}
|
||||
@@ -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<string>();
|
||||
var handler = new DelegateHttpMessageHandler((request, _) =>
|
||||
{
|
||||
requestedPaths.Add(request.RequestUri?.AbsolutePath ?? string.Empty);
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||
});
|
||||
|
||||
var settings = new JellyfinSettings
|
||||
{
|
||||
Url = "http://127.0.0.1:1",
|
||||
ApiKey = "server-api-key",
|
||||
ClientName = "Allstarr",
|
||||
DeviceName = "Allstarr",
|
||||
DeviceId = "allstarr",
|
||||
ClientVersion = "1.0"
|
||||
};
|
||||
|
||||
var proxyService = CreateProxyService(handler, settings);
|
||||
using var manager = new JellyfinSessionManager(
|
||||
proxyService,
|
||||
Options.Create(settings),
|
||||
NullLogger<JellyfinSessionManager>.Instance);
|
||||
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["X-Emby-Authorization"] =
|
||||
"MediaBrowser Client=\"Finamp\", Device=\"Android Auto\", DeviceId=\"dev-123\", Version=\"1.0\", Token=\"abc\""
|
||||
};
|
||||
|
||||
await manager.RegisterProxiedWebSocketAsync("dev-123");
|
||||
|
||||
var ensured = await manager.EnsureSessionAsync("dev-123", "Finamp", "Android Auto", "1.0", headers);
|
||||
|
||||
Assert.True(ensured);
|
||||
Assert.DoesNotContain("/Sessions/Capabilities/Full", requestedPaths);
|
||||
}
|
||||
|
||||
private static JellyfinProxyService CreateProxyService(HttpMessageHandler handler, JellyfinSettings settings)
|
||||
{
|
||||
var httpClientFactory = new TestHttpClientFactory(handler);
|
||||
|
||||
@@ -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<T>(object instance, string methodName, params object?[] args)
|
||||
{
|
||||
var method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
@@ -299,6 +299,65 @@ public class SquidWTFMetadataServiceTests
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAllAsync_WithZeroLimits_SkipsUnusedBuckets()
|
||||
{
|
||||
var requestKinds = new List<string>();
|
||||
var handler = new StubHttpMessageHandler(request =>
|
||||
{
|
||||
var trackQuery = GetQueryParameter(request.RequestUri!, "s");
|
||||
var albumQuery = GetQueryParameter(request.RequestUri!, "al");
|
||||
var artistQuery = GetQueryParameter(request.RequestUri!, "a");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(trackQuery))
|
||||
{
|
||||
requestKinds.Add("song");
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(CreateTrackSearchResponse(CreateTrackPayload(1, "Song", "USRC12345678")))
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(albumQuery))
|
||||
{
|
||||
requestKinds.Add("album");
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(CreateAlbumSearchResponse())
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(artistQuery))
|
||||
{
|
||||
requestKinds.Add("artist");
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(CreateArtistSearchResponse())
|
||||
};
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unexpected request URI: {request.RequestUri}");
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
new List<string> { "https://test1.example.com" });
|
||||
|
||||
var result = await service.SearchAllAsync("OK Computer", 0, 5, 0);
|
||||
|
||||
Assert.Empty(result.Songs);
|
||||
Assert.Single(result.Albums);
|
||||
Assert.Empty(result.Artists);
|
||||
Assert.Equal(new[] { "album" }, requestKinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitFilter_RespectsSettings()
|
||||
{
|
||||
|
||||
@@ -9,5 +9,5 @@ public static class AppVersion
|
||||
/// <summary>
|
||||
/// Current application version.
|
||||
/// </summary>
|
||||
public const string Version = "1.4.3";
|
||||
public const string Version = "1.5.3";
|
||||
}
|
||||
|
||||
@@ -139,6 +139,56 @@ public class DownloadsController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DELETE /api/admin/downloads/all
|
||||
/// Deletes all kept audio files and removes empty folders
|
||||
/// </summary>
|
||||
[HttpDelete("downloads/all")]
|
||||
public IActionResult DeleteAllDownloads()
|
||||
{
|
||||
try
|
||||
{
|
||||
var keptPath = Path.GetFullPath(Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"));
|
||||
if (!Directory.Exists(keptPath))
|
||||
{
|
||||
return Ok(new { success = true, deletedCount = 0, message = "No kept downloads found" });
|
||||
}
|
||||
|
||||
var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" };
|
||||
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
|
||||
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
|
||||
.ToList();
|
||||
|
||||
foreach (var filePath in allFiles)
|
||||
{
|
||||
System.IO.File.Delete(filePath);
|
||||
}
|
||||
|
||||
// Clean up empty directories under kept root (deepest first)
|
||||
var allDirectories = Directory.GetDirectories(keptPath, "*", SearchOption.AllDirectories)
|
||||
.OrderByDescending(d => d.Length);
|
||||
foreach (var directory in allDirectories)
|
||||
{
|
||||
if (!Directory.EnumerateFileSystemEntries(directory).Any())
|
||||
{
|
||||
Directory.Delete(directory);
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
deletedCount = allFiles.Count,
|
||||
message = $"Deleted {allFiles.Count} kept download(s)"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete all kept downloads");
|
||||
return StatusCode(500, new { error = "Failed to delete all kept downloads" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/admin/downloads/file
|
||||
/// Downloads a specific file from the kept folder
|
||||
|
||||
@@ -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<string> PassthroughResponseHeadersToSkip = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Connection",
|
||||
"Keep-Alive",
|
||||
"Proxy-Authenticate",
|
||||
"Proxy-Authorization",
|
||||
"TE",
|
||||
"Trailer",
|
||||
"Transfer-Encoding",
|
||||
"Upgrade",
|
||||
"Content-Type",
|
||||
"Content-Length"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Helper to handle proxy responses with proper status code handling.
|
||||
/// </summary>
|
||||
@@ -48,6 +64,60 @@ public partial class JellyfinController
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private async Task<IActionResult> ProxyJsonPassthroughAsync(string endpoint)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Match the previous proxy semantics for client compatibility.
|
||||
// Some Jellyfin clients/proxies cancel the ASP.NET request token aggressively
|
||||
// even though the upstream request would still complete successfully.
|
||||
var upstreamResponse = await _proxyService.GetPassthroughResponseAsync(
|
||||
endpoint,
|
||||
Request.Headers);
|
||||
|
||||
HttpContext.Response.RegisterForDispose(upstreamResponse);
|
||||
HttpContext.Features.Get<IHttpResponseBodyFeature>()?.DisableBuffering();
|
||||
Response.StatusCode = (int)upstreamResponse.StatusCode;
|
||||
Response.Headers["X-Accel-Buffering"] = "no";
|
||||
|
||||
CopyPassthroughResponseHeaders(upstreamResponse);
|
||||
|
||||
if (upstreamResponse.Content.Headers.ContentLength.HasValue)
|
||||
{
|
||||
Response.ContentLength = upstreamResponse.Content.Headers.ContentLength.Value;
|
||||
}
|
||||
|
||||
var contentType = upstreamResponse.Content.Headers.ContentType?.ToString() ?? "application/json";
|
||||
var stream = await upstreamResponse.Content.ReadAsStreamAsync();
|
||||
|
||||
return new FileStreamResult(stream, contentType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to transparently proxy Jellyfin request for {Endpoint}", endpoint);
|
||||
return StatusCode(502, new { error = "Failed to connect to Jellyfin server" });
|
||||
}
|
||||
}
|
||||
|
||||
private void CopyPassthroughResponseHeaders(HttpResponseMessage upstreamResponse)
|
||||
{
|
||||
foreach (var header in upstreamResponse.Headers)
|
||||
{
|
||||
if (!PassthroughResponseHeadersToSkip.Contains(header.Key))
|
||||
{
|
||||
Response.Headers[header.Key] = header.Value.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var header in upstreamResponse.Content.Headers)
|
||||
{
|
||||
if (!PassthroughResponseHeadersToSkip.Contains(header.Key))
|
||||
{
|
||||
Response.Headers[header.Key] = header.Value.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates ChildCount for Spotify playlists in the response to show total tracks (local + matched).
|
||||
/// </summary>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether Spotify playlist count enrichment should run for a response.
|
||||
/// We only run enrichment for playlist-oriented payloads to avoid mutating unrelated item lists
|
||||
|
||||
@@ -245,7 +245,9 @@ public class JellyfinAdminController : ControllerBase
|
||||
/// Get all playlists from the user's Spotify account
|
||||
/// </summary>
|
||||
[HttpGet("jellyfin/playlists")]
|
||||
public async Task<IActionResult> GetJellyfinPlaylists([FromQuery] string? userId = null)
|
||||
public async Task<IActionResult> 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
|
||||
|
||||
@@ -39,69 +39,9 @@ public partial class JellyfinController
|
||||
{
|
||||
var responseJson = result.RootElement.GetRawText();
|
||||
|
||||
// On successful auth, extract access token and post session capabilities in background
|
||||
if (statusCode == 200)
|
||||
{
|
||||
_logger.LogInformation("Authentication successful");
|
||||
|
||||
// Extract access token from response for session capabilities
|
||||
string? accessToken = null;
|
||||
if (result.RootElement.TryGetProperty("AccessToken", out var tokenEl))
|
||||
{
|
||||
accessToken = tokenEl.GetString();
|
||||
}
|
||||
|
||||
// Post session capabilities in background if we have a token
|
||||
if (!string.IsNullOrEmpty(accessToken))
|
||||
{
|
||||
var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers);
|
||||
// Capture token in closure - don't use Request.Headers (will be disposed)
|
||||
var token = accessToken;
|
||||
var authHeader = AuthHeaderHelper.CreateAuthHeader(token, client, device, deviceId, version);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("🔧 Posting session capabilities after authentication");
|
||||
|
||||
// Build auth header with the new token
|
||||
var authHeaders = new HeaderDictionary
|
||||
{
|
||||
["X-Emby-Authorization"] = authHeader,
|
||||
["X-Emby-Token"] = token
|
||||
};
|
||||
|
||||
var capabilities = new
|
||||
{
|
||||
PlayableMediaTypes = new[] { "Audio" },
|
||||
SupportedCommands = Array.Empty<string>(),
|
||||
SupportsMediaControl = false,
|
||||
SupportsPersistentIdentifier = true,
|
||||
SupportsSync = false
|
||||
};
|
||||
|
||||
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
|
||||
var (capResult, capStatus) =
|
||||
await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", capabilitiesJson,
|
||||
authHeaders);
|
||||
|
||||
if (capStatus == 204 || capStatus == 200)
|
||||
{
|
||||
_logger.LogDebug("✓ Session capabilities posted after auth ({StatusCode})",
|
||||
capStatus);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("⚠ Session capabilities returned {StatusCode} after auth",
|
||||
capStatus);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to post session capabilities after auth");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
|
||||
@@ -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<object>(cacheKey);
|
||||
var cachedResult = await _cache.GetStringAsync(searchCacheKey);
|
||||
|
||||
if (cachedResult != null)
|
||||
if (!string.IsNullOrWhiteSpace(cachedResult))
|
||||
{
|
||||
_logger.LogInformation("SEARCH TRACE: cache hit for key '{CacheKey}'", cacheKey);
|
||||
return new JsonResult(cachedResult);
|
||||
_logger.LogInformation("SEARCH TRACE: cache hit for key '{CacheKey}'", searchCacheKey);
|
||||
return Content(cachedResult, "application/json");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ExternalPlaylist>())
|
||||
@@ -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>(T response) where T : class
|
||||
{
|
||||
return JsonSerializer.Serialize(response, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DictionaryKeyPolicy = null
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets child items of a parent (tracks in album, albums for artist).
|
||||
/// </summary>
|
||||
@@ -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<Song>();
|
||||
var allAlbums = includesAlbums
|
||||
? localAlbums.Concat(externalResult.Albums).Take(limit).ToList()
|
||||
: new List<Album>();
|
||||
var allArtists = includesArtists
|
||||
? localArtists.Concat(externalResult.Artists).Take(limit).ToList()
|
||||
: new List<Artist>();
|
||||
|
||||
return _responseBuilder.CreateSearchHintsResponse(
|
||||
allSongs.Take(limit).ToList(),
|
||||
@@ -751,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<Dictionary<string, object?>> ApplyRequestedAlbumOrderingIfApplicable(
|
||||
List<Dictionary<string, object?>> items,
|
||||
string[]? requestedTypes,
|
||||
string? sortBy,
|
||||
string? sortOrder)
|
||||
{
|
||||
if (items.Count <= 1 || string.IsNullOrWhiteSpace(sortBy))
|
||||
{
|
||||
return items;
|
||||
}
|
||||
|
||||
if (requestedTypes == null || requestedTypes.Length == 0)
|
||||
{
|
||||
return items;
|
||||
}
|
||||
|
||||
var isAlbumOnlyRequest = requestedTypes.All(type =>
|
||||
string.Equals(type, "MusicAlbum", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(type, "Playlist", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!isAlbumOnlyRequest)
|
||||
{
|
||||
return items;
|
||||
}
|
||||
|
||||
var sortFields = sortBy
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(field => !string.IsNullOrWhiteSpace(field))
|
||||
.ToList();
|
||||
|
||||
if (sortFields.Count == 0)
|
||||
{
|
||||
return items;
|
||||
}
|
||||
|
||||
var descending = string.Equals(sortOrder, "Descending", StringComparison.OrdinalIgnoreCase);
|
||||
var sorted = items.ToList();
|
||||
sorted.Sort((left, right) => CompareAlbumItemsByRequestedSort(left, right, sortFields, descending));
|
||||
return sorted;
|
||||
}
|
||||
|
||||
private int CompareAlbumItemsByRequestedSort(
|
||||
Dictionary<string, object?> left,
|
||||
Dictionary<string, object?> right,
|
||||
IReadOnlyList<string> sortFields,
|
||||
bool descending)
|
||||
{
|
||||
foreach (var field in sortFields)
|
||||
{
|
||||
var comparison = CompareAlbumItemsByField(left, right, field);
|
||||
if (comparison == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return descending ? -comparison : comparison;
|
||||
}
|
||||
|
||||
return string.Compare(GetItemStringValue(left, "Name"), GetItemStringValue(right, "Name"), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private int CompareAlbumItemsByField(Dictionary<string, object?> left, Dictionary<string, object?> right, string field)
|
||||
{
|
||||
return field.ToLowerInvariant() switch
|
||||
{
|
||||
"sortname" => string.Compare(GetItemStringValue(left, "SortName"), GetItemStringValue(right, "SortName"), StringComparison.OrdinalIgnoreCase),
|
||||
"name" => string.Compare(GetItemStringValue(left, "Name"), GetItemStringValue(right, "Name"), StringComparison.OrdinalIgnoreCase),
|
||||
"datecreated" => DateTime.Compare(GetItemDateValue(left, "DateCreated"), GetItemDateValue(right, "DateCreated")),
|
||||
"premieredate" => DateTime.Compare(GetItemDateValue(left, "PremiereDate"), GetItemDateValue(right, "PremiereDate")),
|
||||
"productionyear" => CompareIntValues(GetItemIntValue(left, "ProductionYear"), GetItemIntValue(right, "ProductionYear")),
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
private static int CompareIntValues(int? left, int? right)
|
||||
{
|
||||
if (left.HasValue && right.HasValue)
|
||||
{
|
||||
return left.Value.CompareTo(right.Value);
|
||||
}
|
||||
|
||||
if (left.HasValue)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (right.HasValue)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static DateTime GetItemDateValue(Dictionary<string, object?> item, string key)
|
||||
{
|
||||
if (!item.TryGetValue(key, out var value) || value == null)
|
||||
{
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
|
||||
if (value is JsonElement jsonElement)
|
||||
{
|
||||
if (jsonElement.ValueKind == JsonValueKind.String &&
|
||||
DateTime.TryParse(jsonElement.GetString(), out var parsedDate))
|
||||
{
|
||||
return parsedDate;
|
||||
}
|
||||
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(value.ToString(), out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
|
||||
private static int? GetItemIntValue(Dictionary<string, object?> item, string key)
|
||||
{
|
||||
if (!item.TryGetValue(key, out var value) || value == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value is JsonElement jsonElement)
|
||||
{
|
||||
if (jsonElement.ValueKind == JsonValueKind.Number && jsonElement.TryGetInt32(out var intValue))
|
||||
{
|
||||
return intValue;
|
||||
}
|
||||
|
||||
if (jsonElement.ValueKind == JsonValueKind.String &&
|
||||
int.TryParse(jsonElement.GetString(), out var parsedInt))
|
||||
{
|
||||
return parsedInt;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return int.TryParse(value.ToString(), out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Score-sorts each source and then interleaves by highest remaining score.
|
||||
/// This avoids weak head results in one source blocking stronger results later in that same source.
|
||||
/// Merges two source queues without reordering either queue.
|
||||
/// At each step, compare only the current head from each source and dequeue the winner.
|
||||
/// </summary>
|
||||
private List<Dictionary<string, object?>> InterleaveByScore(
|
||||
List<Dictionary<string, object?>> primaryItems,
|
||||
List<Dictionary<string, object?>> secondaryItems,
|
||||
string query,
|
||||
double primaryBoost,
|
||||
double boostMinScore = 70)
|
||||
double primaryBoost)
|
||||
{
|
||||
var primaryScored = primaryItems.Select((item, index) =>
|
||||
var primaryScored = primaryItems.Select(item =>
|
||||
{
|
||||
var baseScore = CalculateItemRelevanceScore(query, item);
|
||||
var finalScore = baseScore >= boostMinScore
|
||||
? Math.Min(100.0, baseScore + primaryBoost)
|
||||
: baseScore;
|
||||
return new
|
||||
{
|
||||
Item = item,
|
||||
BaseScore = baseScore,
|
||||
Score = finalScore,
|
||||
SourceIndex = index
|
||||
Score = Math.Min(100.0, CalculateItemRelevanceScore(query, item) + primaryBoost)
|
||||
};
|
||||
})
|
||||
.OrderByDescending(x => x.Score)
|
||||
.ThenByDescending(x => x.BaseScore)
|
||||
.ThenBy(x => x.SourceIndex)
|
||||
.ToList();
|
||||
|
||||
var secondaryScored = secondaryItems.Select((item, index) =>
|
||||
var secondaryScored = secondaryItems.Select(item =>
|
||||
{
|
||||
var baseScore = CalculateItemRelevanceScore(query, item);
|
||||
return new
|
||||
{
|
||||
Item = item,
|
||||
BaseScore = baseScore,
|
||||
Score = baseScore,
|
||||
SourceIndex = index
|
||||
Score = CalculateItemRelevanceScore(query, item)
|
||||
};
|
||||
})
|
||||
.OrderByDescending(x => x.Score)
|
||||
.ThenByDescending(x => x.BaseScore)
|
||||
.ThenBy(x => x.SourceIndex)
|
||||
.ToList();
|
||||
|
||||
var result = new List<Dictionary<string, object?>>(primaryScored.Count + secondaryScored.Count);
|
||||
int primaryIdx = 0, secondaryIdx = 0;
|
||||
|
||||
while (primaryIdx < primaryScored.Count || secondaryIdx < secondaryScored.Count)
|
||||
while (primaryIdx < primaryScored.Count && secondaryIdx < secondaryScored.Count)
|
||||
{
|
||||
if (primaryIdx >= primaryScored.Count)
|
||||
{
|
||||
result.Add(secondaryScored[secondaryIdx++].Item);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (secondaryIdx >= secondaryScored.Count)
|
||||
{
|
||||
result.Add(primaryScored[primaryIdx++].Item);
|
||||
continue;
|
||||
}
|
||||
|
||||
var primaryCandidate = primaryScored[primaryIdx];
|
||||
var secondaryCandidate = secondaryScored[secondaryIdx];
|
||||
|
||||
if (primaryCandidate.Score > secondaryCandidate.Score)
|
||||
{
|
||||
result.Add(primaryScored[primaryIdx++].Item);
|
||||
}
|
||||
else if (secondaryCandidate.Score > primaryCandidate.Score)
|
||||
{
|
||||
result.Add(secondaryScored[secondaryIdx++].Item);
|
||||
}
|
||||
else if (primaryCandidate.BaseScore >= secondaryCandidate.BaseScore)
|
||||
if (primaryCandidate.Score >= secondaryCandidate.Score)
|
||||
{
|
||||
result.Add(primaryScored[primaryIdx++].Item);
|
||||
}
|
||||
@@ -991,146 +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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates query relevance for a search item.
|
||||
/// Title is primary; metadata context is secondary and down-weighted.
|
||||
/// Calculates query relevance using the product's per-type rules.
|
||||
/// </summary>
|
||||
private double CalculateItemRelevanceScore(string query, Dictionary<string, object?> item)
|
||||
{
|
||||
var title = GetItemName(item);
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
return GetItemType(item) switch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var titleScore = FuzzyMatcher.CalculateSimilarityAggressive(query, title);
|
||||
var searchText = BuildItemSearchText(item, title);
|
||||
|
||||
if (string.Equals(searchText, title, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return titleScore;
|
||||
}
|
||||
|
||||
var metadataScore = FuzzyMatcher.CalculateSimilarityAggressive(query, searchText);
|
||||
var weightedMetadataScore = metadataScore * 0.85;
|
||||
|
||||
var baseScore = Math.Max(titleScore, weightedMetadataScore);
|
||||
return ApplyQueryCoverageAdjustment(query, title, searchText, baseScore);
|
||||
}
|
||||
|
||||
private static double ApplyQueryCoverageAdjustment(string query, string title, string searchText, double baseScore)
|
||||
{
|
||||
var queryTokens = TokenizeForCoverage(query);
|
||||
if (queryTokens.Count < 2)
|
||||
{
|
||||
return baseScore;
|
||||
}
|
||||
|
||||
var titleCoverage = CalculateTokenCoverage(queryTokens, title);
|
||||
var searchCoverage = string.Equals(searchText, title, StringComparison.OrdinalIgnoreCase)
|
||||
? titleCoverage
|
||||
: CalculateTokenCoverage(queryTokens, searchText);
|
||||
|
||||
var coverage = Math.Max(titleCoverage, searchCoverage);
|
||||
|
||||
if (coverage >= 0.999)
|
||||
{
|
||||
return Math.Min(100.0, baseScore + 3.0);
|
||||
}
|
||||
|
||||
if (coverage >= 0.8)
|
||||
{
|
||||
return baseScore * 0.9;
|
||||
}
|
||||
|
||||
if (coverage >= 0.6)
|
||||
{
|
||||
return baseScore * 0.72;
|
||||
}
|
||||
|
||||
return baseScore * 0.5;
|
||||
}
|
||||
|
||||
private static double CalculateTokenCoverage(IReadOnlyList<string> queryTokens, string target)
|
||||
{
|
||||
var targetTokens = TokenizeForCoverage(target);
|
||||
if (queryTokens.Count == 0 || targetTokens.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var matched = 0;
|
||||
foreach (var queryToken in queryTokens)
|
||||
{
|
||||
if (targetTokens.Any(targetToken => IsTokenMatch(queryToken, targetToken)))
|
||||
{
|
||||
matched++;
|
||||
}
|
||||
}
|
||||
|
||||
return (double)matched / queryTokens.Count;
|
||||
}
|
||||
|
||||
private static bool IsTokenMatch(string queryToken, string targetToken)
|
||||
{
|
||||
return queryToken.Equals(targetToken, StringComparison.Ordinal) ||
|
||||
queryToken.StartsWith(targetToken, StringComparison.Ordinal) ||
|
||||
targetToken.StartsWith(queryToken, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> TokenizeForCoverage(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var normalized = NormalizeForCoverage(text);
|
||||
var allTokens = normalized
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (allTokens.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var significant = allTokens
|
||||
.Where(token => token.Length >= 2 && !SearchStopWords.Contains(token))
|
||||
.ToList();
|
||||
|
||||
return significant.Count > 0
|
||||
? significant
|
||||
: allTokens.Where(token => token.Length >= 2).ToList();
|
||||
}
|
||||
|
||||
private static string NormalizeForCoverage(string text)
|
||||
{
|
||||
var normalized = RemoveDiacritics(text).ToLowerInvariant();
|
||||
normalized = normalized.Replace('&', ' ');
|
||||
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"[^\w\s]", " ");
|
||||
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ").Trim();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string RemoveDiacritics(string text)
|
||||
{
|
||||
var normalized = text.Normalize(NormalizationForm.FormD);
|
||||
var chars = new List<char>(normalized.Length);
|
||||
|
||||
foreach (var c in normalized)
|
||||
{
|
||||
if (System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c) != System.Globalization.UnicodeCategory.NonSpacingMark)
|
||||
{
|
||||
chars.Add(c);
|
||||
}
|
||||
}
|
||||
|
||||
return new string(chars.ToArray()).Normalize(NormalizationForm.FormC);
|
||||
"Audio" => CalculateSongRelevanceScore(query, item),
|
||||
"MusicAlbum" => CalculateAlbumRelevanceScore(query, item),
|
||||
"MusicArtist" => CalculateArtistRelevanceScore(query, item),
|
||||
_ => CalculateArtistRelevanceScore(query, item)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1141,52 +912,90 @@ public partial class JellyfinController
|
||||
return GetItemStringValue(item, "Name");
|
||||
}
|
||||
|
||||
private string BuildItemSearchText(Dictionary<string, object?> item, string title)
|
||||
private double CalculateSongRelevanceScore(string query, Dictionary<string, object?> item)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
AddDistinct(parts, title);
|
||||
AddDistinct(parts, GetItemStringValue(item, "SortName"));
|
||||
AddDistinct(parts, GetItemStringValue(item, "AlbumArtist"));
|
||||
AddDistinct(parts, GetItemStringValue(item, "Artist"));
|
||||
AddDistinct(parts, GetItemStringValue(item, "Album"));
|
||||
|
||||
foreach (var artist in GetItemStringList(item, "Artists").Take(3))
|
||||
{
|
||||
AddDistinct(parts, artist);
|
||||
}
|
||||
|
||||
return string.Join(" ", parts);
|
||||
var title = GetItemName(item);
|
||||
var artistText = GetSongArtistText(item);
|
||||
return CalculateBestFuzzyScore(query, title, CombineSearchFields(title, artistText));
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> SearchStopWords = new(StringComparer.Ordinal)
|
||||
private double CalculateAlbumRelevanceScore(string query, Dictionary<string, object?> 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<string> values, string? value)
|
||||
private double CalculateArtistRelevanceScore(string query, Dictionary<string, object?> 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<string, object?> item)
|
||||
{
|
||||
return GetItemStringValue(item, "Type");
|
||||
}
|
||||
|
||||
private string GetSongArtistText(Dictionary<string, object?> item)
|
||||
{
|
||||
var artists = GetItemStringList(item, "Artists").Take(3).ToList();
|
||||
if (artists.Count > 0)
|
||||
{
|
||||
return string.Join(" ", artists);
|
||||
}
|
||||
|
||||
var albumArtist = GetItemStringValue(item, "AlbumArtist");
|
||||
if (!string.IsNullOrWhiteSpace(albumArtist))
|
||||
{
|
||||
return albumArtist;
|
||||
}
|
||||
|
||||
return GetItemStringValue(item, "Artist");
|
||||
}
|
||||
|
||||
private string GetAlbumArtistText(Dictionary<string, object?> item)
|
||||
{
|
||||
var albumArtist = GetItemStringValue(item, "AlbumArtist");
|
||||
if (!string.IsNullOrWhiteSpace(albumArtist))
|
||||
{
|
||||
return albumArtist;
|
||||
}
|
||||
|
||||
var artists = GetItemStringList(item, "Artists").Take(3).ToList();
|
||||
if (artists.Count > 0)
|
||||
{
|
||||
return string.Join(" ", artists);
|
||||
}
|
||||
|
||||
return GetItemStringValue(item, "Artist");
|
||||
}
|
||||
|
||||
private string GetItemStringValue(Dictionary<string, object?> item, string key)
|
||||
|
||||
@@ -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<string?> ResolveCurrentSpotifyPlaylistImageTagAsync(string itemId, string imageType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (itemResult, statusCode) = await _proxyService.GetJsonAsyncInternal($"Items/{itemId}");
|
||||
if (itemResult == null || statusCode != 200)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var itemDocument = itemResult;
|
||||
var imageTag = ExtractImageTag(itemDocument.RootElement, imageType);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(imageTag))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Resolved current Jellyfin {ImageType} image tag for Spotify playlist {PlaylistId}: {ImageTag}",
|
||||
imageType,
|
||||
itemId,
|
||||
imageTag);
|
||||
}
|
||||
|
||||
return imageTag;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex,
|
||||
"Failed to resolve current Jellyfin {ImageType} image tag for Spotify playlist {PlaylistId}",
|
||||
imageType,
|
||||
itemId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -1292,33 +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.)
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
+24
-1
@@ -176,6 +176,25 @@ builder.Services.ConfigureAll<HttpClientFactoryOptions>(options =>
|
||||
// but we want to reduce noise in production logs
|
||||
options.SuppressHandlerScope = true;
|
||||
});
|
||||
|
||||
// Register a dedicated named HttpClient for Jellyfin backend with connection pooling.
|
||||
// SocketsHttpHandler reuses TCP connections across the scoped JellyfinProxyService
|
||||
// instances, eliminating per-request TCP/TLS handshake overhead.
|
||||
builder.Services.AddHttpClient(JellyfinProxyService.HttpClientName)
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
|
||||
{
|
||||
// Keep up to 20 idle connections to Jellyfin alive at any time
|
||||
MaxConnectionsPerServer = 20,
|
||||
// Recycle pooled connections every 5 minutes to pick up DNS changes
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
|
||||
// Close idle connections after 90 seconds to avoid stale sockets
|
||||
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90),
|
||||
// Allow HTTP/2 multiplexing when Jellyfin supports it
|
||||
EnableMultipleHttp2Connections = true,
|
||||
// Follow redirects within Jellyfin
|
||||
AllowAutoRedirect = true,
|
||||
MaxAutomaticRedirections = 5
|
||||
});
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
@@ -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<allstarr.Middleware.AdminNetworkAllowlistMiddleware>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ public class VersionUpgradeRebuildService : IHostedService
|
||||
private readonly SpotifyTrackMatchingService _matchingService;
|
||||
private readonly SpotifyImportSettings _spotifyImportSettings;
|
||||
private readonly ILogger<VersionUpgradeRebuildService> _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<string?> ReadPreviousVersionAsync(CancellationToken cancellationToken)
|
||||
|
||||
@@ -135,10 +135,15 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService
|
||||
|
||||
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Execute searches in parallel
|
||||
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken);
|
||||
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken);
|
||||
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken);
|
||||
var songsTask = songLimit > 0
|
||||
? SearchSongsAsync(query, songLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Song>());
|
||||
var albumsTask = albumLimit > 0
|
||||
? SearchAlbumsAsync(query, albumLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Album>());
|
||||
var artistsTask = artistLimit > 0
|
||||
? SearchArtistsAsync(query, artistLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Artist>());
|
||||
|
||||
await Task.WhenAll(songsTask, albumsTask, artistsTask);
|
||||
|
||||
|
||||
@@ -10,9 +10,17 @@ namespace allstarr.Services.Jellyfin;
|
||||
|
||||
/// <summary>
|
||||
/// Handles proxying requests to the Jellyfin server and authentication.
|
||||
/// Uses a named HttpClient ("JellyfinBackend") with SocketsHttpHandler for
|
||||
/// TCP connection pooling across scoped instances.
|
||||
/// </summary>
|
||||
public class JellyfinProxyService
|
||||
{
|
||||
/// <summary>
|
||||
/// The IHttpClientFactory registration name for the Jellyfin backend client.
|
||||
/// Configured with SocketsHttpHandler for connection pooling in Program.cs.
|
||||
/// </summary>
|
||||
public const string HttpClientName = "JellyfinBackend";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly JellyfinSettings _settings;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
@@ -31,7 +39,7 @@ public class JellyfinProxyService
|
||||
ILogger<JellyfinProxyService> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a proxied GET request to Jellyfin and returns the raw upstream response without buffering the body.
|
||||
/// Intended for transparent passthrough of large JSON payloads that Allstarr does not modify.
|
||||
/// </summary>
|
||||
public async Task<HttpResponseMessage> GetPassthroughResponseAsync(
|
||||
string endpoint,
|
||||
IHeaderDictionary? clientHeaders = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = BuildUrl(endpoint);
|
||||
using var request = CreateClientGetRequest(url, clientHeaders, out var isBrowserStaticRequest, out var isPublicEndpoint);
|
||||
ForwardPassthroughRequestHeaders(clientHeaders, request);
|
||||
|
||||
var response = await _httpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode && !isBrowserStaticRequest && !isPublicEndpoint)
|
||||
{
|
||||
LogUpstreamFailure(HttpMethod.Get, response.StatusCode, url);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async Task<(JsonDocument? Body, int StatusCode)> GetJsonAsyncInternal(string url, IHeaderDictionary? clientHeaders)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<(JsonDocument? Body, int StatusCode)> PostJsonAsync(string endpoint, string body, IHeaderDictionary clientHeaders)
|
||||
{
|
||||
var bodyToSend = body;
|
||||
if (string.IsNullOrWhiteSpace(bodyToSend))
|
||||
{
|
||||
bodyToSend = "{}";
|
||||
_logger.LogWarning("POST body was empty for {Endpoint}, sending empty JSON object", endpoint);
|
||||
}
|
||||
|
||||
return await SendAsync(HttpMethod.Post, endpoint, bodyToSend, clientHeaders, "application/json");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends an arbitrary HTTP request to Jellyfin while preserving the caller's method and body semantics.
|
||||
/// Intended for transparent proxy scenarios such as session control routes.
|
||||
/// </summary>
|
||||
public async Task<(JsonDocument? Body, int StatusCode)> SendAsync(
|
||||
HttpMethod method,
|
||||
string endpoint,
|
||||
string? body,
|
||||
IHeaderDictionary clientHeaders,
|
||||
string? contentType = null)
|
||||
{
|
||||
var url = BuildUrl(endpoint, null);
|
||||
|
||||
using var request = new HttpRequestMessage(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
|
||||
/// </summary>
|
||||
public async Task<(JsonDocument? Body, int StatusCode)> DeleteAsync(string endpoint, IHeaderDictionary clientHeaders)
|
||||
{
|
||||
var url = BuildUrl(endpoint, null);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, url);
|
||||
|
||||
// Forward client IP address to Jellyfin so it can identify the real client
|
||||
if (_httpContextAccessor.HttpContext != null)
|
||||
{
|
||||
var clientIp = _httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
if (!string.IsNullOrEmpty(clientIp))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Forwarded-For", clientIp);
|
||||
request.Headers.TryAddWithoutValidation("X-Real-IP", clientIp);
|
||||
}
|
||||
}
|
||||
|
||||
bool authHeaderAdded = false;
|
||||
|
||||
// Forward authentication headers from client
|
||||
authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
|
||||
|
||||
if (!authHeaderAdded)
|
||||
{
|
||||
_logger.LogDebug("No client auth provided for DELETE {Url} - forwarding without auth", url);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogTrace("Forwarded authentication headers");
|
||||
}
|
||||
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
_logger.LogDebug("DELETE to Jellyfin: {Url}", url);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
LogUpstreamFailure(HttpMethod.Delete, response.StatusCode, url, errorContent);
|
||||
return (null, statusCode);
|
||||
}
|
||||
|
||||
// Handle 204 No Content responses
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
|
||||
{
|
||||
return (null, statusCode);
|
||||
}
|
||||
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Handle empty responses
|
||||
if (string.IsNullOrWhiteSpace(responseContent))
|
||||
{
|
||||
return (null, statusCode);
|
||||
}
|
||||
|
||||
return (JsonDocument.Parse(responseContent), statusCode);
|
||||
return await SendAsync(HttpMethod.Delete, endpoint, null, clientHeaders);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -355,6 +355,7 @@ public class JellyfinResponseBuilder
|
||||
["Tags"] = new string[0],
|
||||
["People"] = new object[0],
|
||||
["SortName"] = songTitle,
|
||||
["AudioInfo"] = new Dictionary<string, object?>(),
|
||||
["ParentLogoItemId"] = song.AlbumId,
|
||||
["ParentBackdropItemId"] = song.AlbumId,
|
||||
["ParentBackdropImageTags"] = new string[0],
|
||||
@@ -405,6 +406,7 @@ public class JellyfinResponseBuilder
|
||||
["MediaType"] = "Audio",
|
||||
["NormalizationGain"] = 0.0,
|
||||
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
|
||||
["CanDelete"] = false,
|
||||
["CanDownload"] = true,
|
||||
["SupportsSync"] = true
|
||||
};
|
||||
@@ -539,6 +541,7 @@ public class JellyfinResponseBuilder
|
||||
["ServerId"] = "allstarr",
|
||||
["Id"] = album.Id,
|
||||
["PremiereDate"] = album.Year.HasValue ? $"{album.Year}-01-01T05:00:00.0000000Z" : null,
|
||||
["DateCreated"] = album.Year.HasValue ? $"{album.Year}-01-01T05:00:00.0000000Z" : "1970-01-01T00:00:00.0000000Z",
|
||||
["ChannelId"] = (object?)null,
|
||||
["Genres"] = !string.IsNullOrEmpty(album.Genre)
|
||||
? new[] { album.Genre }
|
||||
@@ -547,6 +550,8 @@ public class JellyfinResponseBuilder
|
||||
["ProductionYear"] = album.Year,
|
||||
["IsFolder"] = true,
|
||||
["Type"] = "MusicAlbum",
|
||||
["SortName"] = albumName,
|
||||
["BasicSyncInfo"] = new Dictionary<string, object?>(),
|
||||
["GenreItems"] = !string.IsNullOrEmpty(album.Genre)
|
||||
? new[]
|
||||
{
|
||||
@@ -633,6 +638,9 @@ public class JellyfinResponseBuilder
|
||||
["RunTimeTicks"] = 0,
|
||||
["IsFolder"] = true,
|
||||
["Type"] = "MusicArtist",
|
||||
["SortName"] = artistName,
|
||||
["PrimaryImageAspectRatio"] = 1.0,
|
||||
["BasicSyncInfo"] = new Dictionary<string, object?>(),
|
||||
["GenreItems"] = new Dictionary<string, object?>[0],
|
||||
["UserData"] = new Dictionary<string, object>
|
||||
{
|
||||
@@ -755,6 +763,11 @@ public class JellyfinResponseBuilder
|
||||
["RunTimeTicks"] = playlist.Duration * TimeSpan.TicksPerSecond,
|
||||
["IsFolder"] = true,
|
||||
["Type"] = "MusicAlbum",
|
||||
["SortName"] = $"{playlist.Name} [S/P]",
|
||||
["DateCreated"] = playlist.CreatedDate.HasValue
|
||||
? playlist.CreatedDate.Value.ToString("o")
|
||||
: "1970-01-01T00:00:00.0000000Z",
|
||||
["BasicSyncInfo"] = new Dictionary<string, object?>(),
|
||||
["GenreItems"] = new Dictionary<string, object?>[0],
|
||||
["UserData"] = new Dictionary<string, object>
|
||||
{
|
||||
|
||||
@@ -20,6 +20,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
private readonly ILogger<JellyfinSessionManager> _logger;
|
||||
private readonly ConcurrentDictionary<string, SessionInfo> _sessions = new();
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> _sessionInitLocks = new();
|
||||
private readonly ConcurrentDictionary<string, byte> _proxiedWebSocketConnections = new();
|
||||
private readonly Timer _keepAliveTimer;
|
||||
|
||||
public JellyfinSessionManager(
|
||||
@@ -53,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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
public async Task RemoveSessionAsync(string deviceId)
|
||||
{
|
||||
_proxiedWebSocketConnections.TryRemove(deviceId, out _);
|
||||
|
||||
if (_sessions.TryRemove(deviceId, out var session))
|
||||
{
|
||||
_logger.LogDebug("🗑️ SESSION: Removing session for device {DeviceId}", deviceId);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,9 +160,15 @@ public class QobuzMetadataService : TrackParserBase, IMusicMetadataService
|
||||
|
||||
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken);
|
||||
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken);
|
||||
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken);
|
||||
var songsTask = songLimit > 0
|
||||
? SearchSongsAsync(query, songLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Song>());
|
||||
var albumsTask = albumLimit > 0
|
||||
? SearchAlbumsAsync(query, albumLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Album>());
|
||||
var artistsTask = artistLimit > 0
|
||||
? SearchArtistsAsync(query, artistLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Artist>());
|
||||
|
||||
await Task.WhenAll(songsTask, albumsTask, artistsTask);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -247,6 +247,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
|
||||
// Re-fetch
|
||||
await GetPlaylistTracksAsync(playlistName);
|
||||
await ClearPlaylistImageCacheAsync(playlistName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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("========================================");
|
||||
|
||||
@@ -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<string, DateTime> _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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public method to trigger full rebuild for all playlists (called from "Rebuild All Remote" button).
|
||||
/// This clears caches, fetches fresh data, and re-matches everything immediately.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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<List<(Song Song, double Score)>> TryMatchByFuzzyMultipleAsync(
|
||||
string title,
|
||||
List<string> 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<JellyfinProxyService>();
|
||||
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<JellyfinProxyService>();
|
||||
if (proxyService != null)
|
||||
try
|
||||
{
|
||||
try
|
||||
// Search Jellyfin for local tracks
|
||||
var searchParams = new Dictionary<string, string>
|
||||
{
|
||||
// Search Jellyfin for local tracks
|
||||
var searchParams = new Dictionary<string, string>
|
||||
{
|
||||
["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<Song>();
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var localResults = new List<Song>();
|
||||
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.
|
||||
/// </summary>
|
||||
private async Task<Song?> TryMatchByIsrcAsync(string isrc, IMusicMetadataService metadataService)
|
||||
private async Task<Song?> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -498,14 +498,19 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
||||
|
||||
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Execute searches in parallel
|
||||
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken);
|
||||
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken);
|
||||
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken);
|
||||
var songsTask = songLimit > 0
|
||||
? SearchSongsAsync(query, songLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Song>());
|
||||
var albumsTask = albumLimit > 0
|
||||
? SearchAlbumsAsync(query, albumLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Album>());
|
||||
var artistsTask = artistLimit > 0
|
||||
? SearchArtistsAsync(query, artistLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Artist>());
|
||||
|
||||
await Task.WhenAll(songsTask, albumsTask, artistsTask);
|
||||
|
||||
var temp = new SearchResult
|
||||
var temp = new SearchResult
|
||||
{
|
||||
Songs = await songsTask,
|
||||
Albums = await albumsTask,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path fill="#FFDD00" d="M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 00-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 00-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 01-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 013.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 01-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 01-4.743.295 37.059 37.059 0 01-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0011.343.376.483.483 0 01.535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 01.39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 01-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 01-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 00-1.322-.238c-.826 0-1.491.284-2.26.613z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 98 96" aria-hidden="true"><path fill="#f0f6fc" fill-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 980 B |
@@ -0,0 +1,13 @@
|
||||
<svg width="241" height="194" viewBox="0 0 241 194" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_1_219" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="-1" y="0" width="242" height="194">
|
||||
<path d="M240.469 0.958984H-0.00585938V193.918H240.469V0.958984Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_1_219)">
|
||||
<path d="M96.1344 193.911C61.1312 193.911 32.6597 178.256 15.9721 149.829C1.19788 124.912 -0.00585938 97.9229 -0.00585938 67.7662C-0.00585938 49.8876 5.37293 34.3215 15.5413 22.7466C24.8861 12.1157 38.1271 5.22907 52.8317 3.35378C70.2858 1.14271 91.9848 0.958984 114.545 0.958984C151.259 0.958984 161.63 1.4088 176.075 2.85328C195.29 4.76026 211.458 11.932 222.824 23.5955C234.368 35.4428 240.469 51.2624 240.469 69.3627V72.9994C240.469 103.885 219.821 129.733 191.046 136.759C188.898 141.827 186.237 146.871 183.089 151.837L183.006 151.964C172.869 167.632 149.042 193.918 103.401 193.918H96.1281L96.1344 193.911Z" fill="white"/>
|
||||
<path d="M174.568 17.9772C160.927 16.6151 151.38 16.1589 114.552 16.1589C90.908 16.1589 70.9008 16.387 54.7644 18.4334C33.3949 21.164 15.2058 37.5285 15.2058 67.7674C15.2058 98.0066 16.796 121.422 29.0741 142.107C42.9425 165.751 66.1302 178.707 96.1412 178.707H103.414C140.242 178.707 160.25 159.156 170.253 143.698C174.574 136.874 177.754 130.058 179.801 123.234C205.947 120.96 225.27 99.3624 225.27 72.9941V69.3577C225.27 40.9432 206.631 21.164 174.574 17.9772H174.568Z" fill="white"/>
|
||||
<path d="M15.1975 67.7674C15.1975 37.5285 33.3866 21.164 54.7559 18.4334C70.8987 16.387 90.906 16.1589 114.544 16.1589C151.372 16.1589 160.919 16.6151 174.559 17.9772C206.617 21.1576 225.255 40.937 225.255 69.3577V72.9941C225.255 99.3687 205.932 120.966 179.786 123.234C177.74 130.058 174.559 136.874 170.238 143.698C160.235 159.156 140.228 178.707 103.4 178.707H96.1264C66.1155 178.707 42.9277 165.751 29.0595 142.107C16.7814 121.422 15.1912 98.4563 15.1912 67.7674" fill="#202020"/>
|
||||
<path d="M32.2469 67.9899C32.2469 97.3168 34.0654 116.184 43.6127 133.689C54.5225 153.924 74.3018 161.653 96.8117 161.653H103.857C133.411 161.653 147.736 147.329 155.693 134.829C159.558 128.462 162.966 121.417 164.784 112.547L166.147 106.864H174.332C192.521 106.864 208.208 92.09 208.208 73.2166V69.8082C208.208 48.6669 195.024 37.5228 172.058 34.7987C159.102 33.6646 151.372 33.2084 114.538 33.2084C89.7602 33.2084 72.0272 33.4364 58.6152 35.4828C39.7483 38.2134 32.2407 48.8951 32.2407 67.9899" fill="white"/>
|
||||
<path d="M166.158 83.6801C166.158 86.4107 168.204 88.4572 171.841 88.4572C183.435 88.4572 189.802 81.8619 189.802 70.9523C189.802 60.0427 183.435 53.2195 171.841 53.2195C168.204 53.2195 166.158 55.2657 166.158 57.9963V83.6866V83.6801Z" fill="#202020"/>
|
||||
<path d="M54.5321 82.3198C54.5321 95.732 62.0332 107.326 71.5807 116.424C77.9478 122.562 87.9515 128.93 94.7685 133.022C96.8147 134.157 98.8611 134.841 101.136 134.841C103.866 134.841 106.134 134.157 107.959 133.022C114.782 128.93 124.779 122.562 130.919 116.424C140.694 107.332 148.195 95.7383 148.195 82.3198C148.195 67.7673 137.286 54.8115 121.599 54.8115C112.28 54.8115 105.912 59.5882 101.136 66.1772C96.8147 59.582 90.2259 54.8115 80.9001 54.8115C64.9855 54.8115 54.5256 67.7673 54.5256 82.3198" fill="#FF5A16"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
+128
-62
@@ -12,8 +12,8 @@
|
||||
<!-- Restart Required Banner -->
|
||||
<div class="restart-banner" id="restart-banner">
|
||||
⚠️ Configuration changed. Restart required to apply changes.
|
||||
<button onclick="restartContainer()">Restart Allstarr</button>
|
||||
<button onclick="dismissRestartBanner()"
|
||||
<button data-action="restartContainer">Restart Allstarr</button>
|
||||
<button data-action="dismissRestartBanner"
|
||||
style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button>
|
||||
</div>
|
||||
|
||||
@@ -32,46 +32,86 @@
|
||||
<div class="auth-error" id="auth-error" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="support-badge">
|
||||
<p class="support-text">
|
||||
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
|
||||
supporting its development
|
||||
</p>
|
||||
<ul class="support-funding-icons">
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://ko-fi.com/joshpatra" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="Support on Ko-fi">
|
||||
<img src="images/kofi_symbol.svg" alt="" width="37" height="30" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://github.com/sponsors/SoPat712" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="GitHub Sponsors">
|
||||
<img src="images/github-mark.svg" alt="" width="30" height="29" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://buymeacoffee.com/treeman183" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="Buy Me a Coffee">
|
||||
<img src="images/buymeacoffee-symbol.svg" alt="" width="30" height="30" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container" id="main-container" style="display:none;">
|
||||
<header>
|
||||
<h1>
|
||||
Allstarr <span class="version" id="version">Loading...</span>
|
||||
</h1>
|
||||
<div class="header-actions">
|
||||
<div class="auth-user" id="auth-user-display" style="display:none;">
|
||||
Signed in as <strong id="auth-user-name">-</strong>
|
||||
<div class="container hidden" id="main-container">
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" aria-label="Admin navigation">
|
||||
<div class="sidebar-brand">
|
||||
<div class="sidebar-title">Allstarr</div>
|
||||
<div class="sidebar-subtitle" id="sidebar-version">Loading...</div>
|
||||
</div>
|
||||
<button id="auth-logout-btn" onclick="logoutAdminSession()" style="display:none;">Logout</button>
|
||||
<div id="status-indicator">
|
||||
<span class="status-badge" id="spotify-status">
|
||||
<span class="status-dot"></span>
|
||||
<span>Loading...</span>
|
||||
</span>
|
||||
<nav class="sidebar-nav">
|
||||
<button class="sidebar-link active" type="button" data-tab="dashboard">Dashboard</button>
|
||||
<button class="sidebar-link" type="button" data-tab="jellyfin-playlists">Link Playlists</button>
|
||||
<button class="sidebar-link" type="button" data-tab="playlists">Injected Playlists</button>
|
||||
<button class="sidebar-link" type="button" data-tab="kept">Kept Downloads</button>
|
||||
<button class="sidebar-link" type="button" data-tab="scrobbling">Scrobbling</button>
|
||||
<button class="sidebar-link" type="button" data-tab="config">Configuration</button>
|
||||
<button class="sidebar-link" type="button" data-tab="endpoints">API Analytics</button>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="auth-user hidden" id="auth-user-display">
|
||||
Signed in as <strong id="auth-user-name">-</strong>
|
||||
</div>
|
||||
<button id="auth-logout-btn" data-action="logoutAdminSession" class="hidden">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</aside>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
||||
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
||||
<div class="tab" data-tab="playlists">Injected Playlists</div>
|
||||
<div class="tab" data-tab="kept">Kept Downloads</div>
|
||||
<div class="tab" data-tab="scrobbling">Scrobbling</div>
|
||||
<div class="tab" data-tab="config">Configuration</div>
|
||||
<div class="tab" data-tab="endpoints">API Analytics</div>
|
||||
</div>
|
||||
<main class="app-main">
|
||||
<header class="app-header">
|
||||
<h1>
|
||||
Allstarr <span class="version" id="version">Loading...</span>
|
||||
</h1>
|
||||
<div class="header-actions">
|
||||
<div id="status-indicator">
|
||||
<span class="status-badge" id="spotify-status">
|
||||
<span class="status-dot"></span>
|
||||
<span>Loading...</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="tabs top-tabs" aria-hidden="true">
|
||||
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
||||
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
||||
<div class="tab" data-tab="playlists">Injected Playlists</div>
|
||||
<div class="tab" data-tab="kept">Kept Downloads</div>
|
||||
<div class="tab" data-tab="scrobbling">Scrobbling</div>
|
||||
<div class="tab" data-tab="config">Configuration</div>
|
||||
<div class="tab" data-tab="endpoints">API Analytics</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Tab -->
|
||||
<div class="tab-content active" id="tab-dashboard">
|
||||
<div class="card" id="download-activity-card">
|
||||
<h2>Live Download Queue</h2>
|
||||
<div id="download-activity-list" class="download-queue-list">
|
||||
<div class="empty-state">No active downloads</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2>Spotify API</h2>
|
||||
@@ -120,9 +160,9 @@
|
||||
</h2>
|
||||
<div id="dashboard-guidance" class="guidance-stack"></div>
|
||||
<div class="card-actions-row">
|
||||
<button class="primary" onclick="refreshPlaylists()">Refresh All Playlists</button>
|
||||
<button onclick="clearCache()">Clear Cache</button>
|
||||
<button onclick="openAddPlaylist()">Add Playlist</button>
|
||||
<button class="primary" data-action="refreshPlaylists">Refresh All Playlists</button>
|
||||
<button data-action="clearCache">Clear Cache</button>
|
||||
<button data-action="openAddPlaylist">Add Playlist</button>
|
||||
<button onclick="window.location.href='/spotify-mappings.html'">View Spotify Mappings</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,7 +177,7 @@
|
||||
<button onclick="fetchJellyfinPlaylists()">Refresh</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
<p class="text-secondary mb-16">
|
||||
Connect your Jellyfin playlists to Spotify playlists. Allstarr will automatically fill in missing
|
||||
tracks from Spotify using your preferred music service (SquidWTF/Deezer/Qobuz).
|
||||
<br><strong>Tip:</strong> Use the sp_dc cookie method for best results - it's simpler and more
|
||||
@@ -145,10 +185,9 @@
|
||||
</p>
|
||||
<div id="jellyfin-guidance" class="guidance-stack"></div>
|
||||
|
||||
<div id="jellyfin-user-filter" style="display: flex; gap: 16px; margin-bottom: 16px; flex-wrap: wrap;">
|
||||
<div class="form-group" style="margin: 0; flex: 1; min-width: 200px;">
|
||||
<label
|
||||
style="display: block; margin-bottom: 4px; color: var(--text-secondary); font-size: 0.85rem;">User</label>
|
||||
<div id="jellyfin-user-filter" class="flex-row-wrap mb-16">
|
||||
<div class="form-group jellyfin-user-form-group">
|
||||
<label class="text-secondary">User</label>
|
||||
<select id="jellyfin-user-select" onchange="fetchJellyfinPlaylists()"
|
||||
style="width: 100%; padding: 8px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);">
|
||||
<option value="">All Users</option>
|
||||
@@ -224,7 +263,7 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||
<p class="text-secondary mb-12">
|
||||
These are the Spotify playlists currently being injected into Jellyfin with tracks from your music
|
||||
service.
|
||||
</p>
|
||||
@@ -260,15 +299,14 @@
|
||||
Manual mappings override automatic matching for external providers (SquidWTF, Deezer, Qobuz). For
|
||||
local Jellyfin tracks, use the Spotify Import plugin instead.
|
||||
</p>
|
||||
<div id="mappings-summary"
|
||||
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
||||
<div id="mappings-summary" class="summary-box">
|
||||
<div>
|
||||
<span style="color: var(--text-secondary);">Total:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px;" id="mappings-total">0</span>
|
||||
<span class="summary-label">Total:</span>
|
||||
<span class="summary-value" id="mappings-total">0</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="color: var(--text-secondary);">External:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--success);"
|
||||
<span class="summary-label">External:</span>
|
||||
<span class="summary-value success"
|
||||
id="mappings-external">0</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -301,15 +339,14 @@
|
||||
<button onclick="fetchMissingTracks()">Refresh</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||
<p class="text-secondary mb-12">
|
||||
Tracks that couldn't be matched locally or externally. Map them manually to add them to your
|
||||
playlists.
|
||||
</p>
|
||||
<div id="missing-summary"
|
||||
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
||||
<div id="missing-summary" class="summary-box">
|
||||
<div>
|
||||
<span style="color: var(--text-secondary);">Total Missing:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--warning);"
|
||||
<span class="summary-label">Total Missing:</span>
|
||||
<span class="summary-value warning"
|
||||
id="missing-total">0</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -340,23 +377,23 @@
|
||||
<h2>
|
||||
Kept Downloads
|
||||
<div class="actions">
|
||||
<button onclick="downloadAllKept()" style="background:var(--accent);border-color:var(--accent);">Download All</button>
|
||||
<button onclick="downloadAllKept()" class="primary">Download All</button>
|
||||
<button onclick="deleteAllKept()" class="danger">Delete All</button>
|
||||
<button onclick="fetchDownloads()">Refresh</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||
<p class="text-secondary mb-12">
|
||||
Downloaded files stored permanently. Download individual tracks or download all as a zip archive.
|
||||
</p>
|
||||
<div id="downloads-summary"
|
||||
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
||||
<div id="downloads-summary" class="summary-box">
|
||||
<div>
|
||||
<span style="color: var(--text-secondary);">Total Files:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);"
|
||||
<span class="summary-label">Total Files:</span>
|
||||
<span class="summary-value accent"
|
||||
id="downloads-count">0</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="color: var(--text-secondary);">Total Size:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);" id="downloads-size">0
|
||||
<span class="summary-label">Total Size:</span>
|
||||
<span class="summary-value accent" id="downloads-size">0
|
||||
B</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -954,6 +991,35 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="support-footer">
|
||||
<p class="support-text">
|
||||
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
|
||||
supporting its development
|
||||
</p>
|
||||
<ul class="support-funding-icons">
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://ko-fi.com/joshpatra" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="Support on Ko-fi">
|
||||
<img src="images/kofi_symbol.svg" alt="" width="37" height="30" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://github.com/sponsors/SoPat712" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="GitHub Sponsors">
|
||||
<img src="images/github-mark.svg" alt="" width="30" height="29" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://buymeacoffee.com/treeman183" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="Buy Me a Coffee">
|
||||
<img src="images/buymeacoffee-symbol.svg" alt="" width="30" height="30" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Playlist Modal -->
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
function toBoolean(value) {
|
||||
if (value === true || value === false) {
|
||||
return value;
|
||||
}
|
||||
const normalized = String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return normalized === "true" || normalized === "1" || normalized === "yes";
|
||||
}
|
||||
|
||||
function toNumber(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function getActionArgs(el) {
|
||||
if (!el || !el.dataset) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Convention:
|
||||
// - data-action="foo"
|
||||
// - data-arg-bar="baz" => { bar: "baz" }
|
||||
const args = {};
|
||||
for (const [key, value] of Object.entries(el.dataset)) {
|
||||
if (!key.startsWith("arg")) continue;
|
||||
const argName = key.slice(3);
|
||||
if (!argName) continue;
|
||||
const normalized =
|
||||
argName.charAt(0).toLowerCase() + argName.slice(1);
|
||||
args[normalized] = value;
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
export function initActionDispatcher({ root = document } = {}) {
|
||||
const handlers = new Map();
|
||||
|
||||
function register(actionName, handler) {
|
||||
if (!actionName || typeof handler !== "function") {
|
||||
return;
|
||||
}
|
||||
handlers.set(actionName, handler);
|
||||
}
|
||||
|
||||
async function dispatch(actionName, el, event = null) {
|
||||
const handler = handlers.get(actionName);
|
||||
const args = getActionArgs(el);
|
||||
|
||||
if (handler) {
|
||||
return await handler({ el, event, args, toBoolean, toNumber });
|
||||
}
|
||||
|
||||
// Transitional fallback: if a legacy window function exists, call it.
|
||||
// This allows incremental conversion away from inline onclick.
|
||||
const legacy = typeof window !== "undefined" ? window[actionName] : null;
|
||||
if (typeof legacy === "function") {
|
||||
const legacyArgs = args && Object.keys(args).length > 0 ? [args] : [];
|
||||
return legacy(...legacyArgs);
|
||||
}
|
||||
|
||||
console.warn(`No handler registered for action "${actionName}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
function bind() {
|
||||
root.addEventListener("click", (event) => {
|
||||
const trigger = event.target?.closest?.("[data-action]");
|
||||
if (!trigger) return;
|
||||
|
||||
const actionName = trigger.getAttribute("data-action") || "";
|
||||
if (!actionName) return;
|
||||
|
||||
event.preventDefault();
|
||||
dispatch(actionName, trigger, event);
|
||||
});
|
||||
}
|
||||
|
||||
bind();
|
||||
|
||||
return { register, dispatch };
|
||||
}
|
||||
|
||||
@@ -124,6 +124,14 @@ export async function deleteDownload(path) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteAllDownloads() {
|
||||
return requestJson(
|
||||
"/api/admin/downloads/all",
|
||||
{ method: "DELETE" },
|
||||
"Failed to delete all downloads",
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchConfig() {
|
||||
return requestJson(
|
||||
"/api/admin/config",
|
||||
@@ -144,10 +152,15 @@ export async function fetchJellyfinUsers() {
|
||||
return requestOptionalJson("/api/admin/jellyfin/users");
|
||||
}
|
||||
|
||||
export async function fetchJellyfinPlaylists(userId = null) {
|
||||
export async function fetchJellyfinPlaylists(userId = null, includeStats = true) {
|
||||
let url = "/api/admin/jellyfin/playlists";
|
||||
const params = [];
|
||||
if (userId) {
|
||||
url += "?userId=" + encodeURIComponent(userId);
|
||||
params.push("userId=" + encodeURIComponent(userId));
|
||||
}
|
||||
params.push("includeStats=" + String(Boolean(includeStats)));
|
||||
if (params.length > 0) {
|
||||
url += "?" + params.join("&");
|
||||
}
|
||||
|
||||
return requestJson(url, {}, "Failed to fetch Jellyfin playlists");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { escapeHtml, showToast, formatCookieAge } from "./utils.js";
|
||||
import { escapeHtml, escapeJs, showToast, formatCookieAge } from "./utils.js";
|
||||
import * as API from "./api.js";
|
||||
import * as UI from "./ui.js";
|
||||
import { renderCookieAge } from "./settings-editor.js";
|
||||
@@ -15,6 +15,7 @@ let onCookieNeedsInit = async () => {};
|
||||
let setCurrentConfigState = () => {};
|
||||
let syncConfigUiExtras = () => {};
|
||||
let loadScrobblingConfig = () => {};
|
||||
let jellyfinPlaylistRequestToken = 0;
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
@@ -129,6 +130,7 @@ async function fetchMissingTracks() {
|
||||
missing.forEach((t) => {
|
||||
missingTracks.push({
|
||||
playlist: playlist.name,
|
||||
provider: t.externalProvider || t.provider || "squidwtf",
|
||||
...t,
|
||||
});
|
||||
});
|
||||
@@ -151,6 +153,7 @@ async function fetchMissingTracks() {
|
||||
const artist =
|
||||
t.artists && t.artists.length > 0 ? t.artists.join(", ") : "";
|
||||
const searchQuery = `${t.title} ${artist}`;
|
||||
const provider = t.provider || "squidwtf";
|
||||
const trackPosition = Number.isFinite(t.position)
|
||||
? Number(t.position)
|
||||
: 0;
|
||||
@@ -163,7 +166,7 @@ async function fetchMissingTracks() {
|
||||
<td class="mapping-actions-cell">
|
||||
<button class="map-action-btn map-action-search missing-track-search-btn"
|
||||
data-query="${escapeHtml(searchQuery)}"
|
||||
data-provider="squidwtf">🔍 Search</button>
|
||||
data-provider="${escapeHtml(provider)}">🔍 Search</button>
|
||||
<button class="map-action-btn map-action-local missing-track-local-btn"
|
||||
data-playlist="${escapeHtml(t.playlist)}"
|
||||
data-position="${trackPosition}"
|
||||
@@ -213,9 +216,9 @@ async function fetchDownloads() {
|
||||
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
|
||||
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
|
||||
<td>
|
||||
<button onclick="downloadFile('${escapeJs(f.path)}')"
|
||||
<button data-action="downloadFile" data-arg-path="${escapeHtml(escapeJs(f.path))}"
|
||||
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button>
|
||||
<button onclick="deleteDownload('${escapeJs(f.path)}')"
|
||||
<button data-action="deleteDownload" data-arg-path="${escapeHtml(escapeJs(f.path))}"
|
||||
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -245,11 +248,28 @@ async function fetchJellyfinPlaylists() {
|
||||
'<tr><td colspan="4" class="loading"><span class="spinner"></span> Loading Jellyfin playlists...</td></tr>';
|
||||
|
||||
try {
|
||||
const requestToken = ++jellyfinPlaylistRequestToken;
|
||||
const userId = isAdminSession()
|
||||
? document.getElementById("jellyfin-user-select")?.value
|
||||
: null;
|
||||
const data = await API.fetchJellyfinPlaylists(userId);
|
||||
UI.updateJellyfinPlaylistsUI(data);
|
||||
const baseData = await API.fetchJellyfinPlaylists(userId, false);
|
||||
if (requestToken !== jellyfinPlaylistRequestToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
UI.updateJellyfinPlaylistsUI(baseData);
|
||||
|
||||
// Enrich counts after initial render so big accounts don't appear empty.
|
||||
API.fetchJellyfinPlaylists(userId, true)
|
||||
.then((statsData) => {
|
||||
if (requestToken !== jellyfinPlaylistRequestToken) {
|
||||
return;
|
||||
}
|
||||
UI.updateJellyfinPlaylistsUI(statsData);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to fetch Jellyfin playlist track stats:", err);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch Jellyfin playlists:", error);
|
||||
tbody.innerHTML =
|
||||
@@ -346,7 +366,10 @@ function startDashboardRefresh() {
|
||||
fetchPlaylists();
|
||||
fetchTrackMappings();
|
||||
fetchMissingTracks();
|
||||
fetchDownloads();
|
||||
const keptTab = document.getElementById("tab-kept");
|
||||
if (keptTab && keptTab.classList.contains("active")) {
|
||||
fetchDownloads();
|
||||
}
|
||||
|
||||
const endpointsTab = document.getElementById("tab-endpoints");
|
||||
if (endpointsTab && endpointsTab.classList.contains("active")) {
|
||||
@@ -380,7 +403,6 @@ async function loadDashboardData() {
|
||||
}
|
||||
|
||||
startDashboardRefresh();
|
||||
startDownloadActivityStream();
|
||||
}
|
||||
|
||||
function startDownloadActivityStream() {
|
||||
|
||||
@@ -100,14 +100,14 @@ export async function viewTracks(name) {
|
||||
const durationSeconds = Math.floor((t.durationMs || 0) / 1000);
|
||||
const externalSearchLink =
|
||||
t.isLocal === false && t.searchQuery && t.externalProvider
|
||||
? `<br><small style="color:var(--accent)"><a href="#" onclick="searchProvider('${escapeJs(t.searchQuery)}', '${escapeJs(t.externalProvider)}'); return false;" style="color:var(--accent);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>`
|
||||
? `<br><small style="color:var(--accent)"><a href="#" data-action="searchProvider" data-arg-query="${escapeHtml(escapeJs(t.searchQuery))}" data-arg-provider="${escapeHtml(escapeJs(t.externalProvider))}" style="color:var(--accent);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>`
|
||||
: "";
|
||||
const missingSearchLink =
|
||||
t.isLocal === null && t.searchQuery
|
||||
? `<br><small style="color:var(--text-secondary)"><a href="#" onclick="searchProvider('${escapeJs(t.searchQuery)}', 'squidwtf'); return false;" style="color:var(--text-secondary);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>`
|
||||
? `<br><small style="color:var(--text-secondary)"><a href="#" data-action="searchProvider" data-arg-query="${escapeHtml(escapeJs(t.searchQuery))}" data-arg-provider="squidwtf" style="color:var(--text-secondary);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>`
|
||||
: "";
|
||||
|
||||
const lyricsMapButton = `<button class="small" onclick="openLyricsMap('${escapeJs(firstArtist)}', '${escapeJs(t.title)}', '${escapeJs(t.album || "")}', ${durationSeconds})" style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">Map Lyrics ID</button>`;
|
||||
const lyricsMapButton = `<button class="small" data-action="openLyricsMap" data-arg-artist="${escapeHtml(escapeJs(firstArtist))}" data-arg-title="${escapeHtml(escapeJs(t.title))}" data-arg-album="${escapeHtml(escapeJs(t.album || ""))}" data-arg-duration-seconds="${durationSeconds}" style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">Map Lyrics ID</button>`;
|
||||
|
||||
return `
|
||||
<div class="track-item" data-position="${t.position}">
|
||||
@@ -246,7 +246,7 @@ export async function searchJellyfinTracks() {
|
||||
const artist = track.artist || "";
|
||||
const album = track.album || "";
|
||||
return `
|
||||
<div class="jellyfin-result" data-jellyfin-id="${escapeHtml(id)}" onclick="selectJellyfinTrack('${escapeJs(id)}')">
|
||||
<div class="jellyfin-result" data-jellyfin-id="${escapeHtml(id)}" data-action="selectJellyfinTrack" data-arg-jellyfin-id="${escapeHtml(escapeJs(id))}">
|
||||
<div>
|
||||
<strong>${escapeHtml(title)}</strong>
|
||||
<br>
|
||||
@@ -344,7 +344,15 @@ export async function searchExternalTracks() {
|
||||
const externalUrl = track.url || "";
|
||||
|
||||
return `
|
||||
<div class="external-result" data-result-index="${index}" data-external-id="${escapeHtml(id)}" onclick="selectExternalTrack(${index}, '${escapeJs(id)}', '${escapeJs(title)}', '${escapeJs(artist)}', '${escapeJs(providerName)}', '${escapeJs(externalUrl)}')">
|
||||
<div class="external-result" data-result-index="${index}" data-external-id="${escapeHtml(id)}"
|
||||
data-action="selectExternalTrack"
|
||||
data-arg-result-index="${index}"
|
||||
data-arg-external-id="${escapeHtml(escapeJs(id))}"
|
||||
data-arg-title="${escapeHtml(escapeJs(title))}"
|
||||
data-arg-artist="${escapeHtml(escapeJs(artist))}"
|
||||
data-arg-provider="${escapeHtml(escapeJs(providerName))}"
|
||||
data-arg-external-url="${escapeHtml(escapeJs(externalUrl))}"
|
||||
>
|
||||
<div>
|
||||
<strong>${escapeHtml(title)}</strong>
|
||||
<br>
|
||||
@@ -662,13 +670,26 @@ export async function saveLyricsMapping() {
|
||||
// Search provider (open in new tab)
|
||||
export async function searchProvider(query, provider) {
|
||||
try {
|
||||
const data = await API.getSquidWTFBaseUrl();
|
||||
const baseUrl = data.baseUrl; // Use the actual property name from API
|
||||
const searchUrl = `${baseUrl}/music/search?q=${encodeURIComponent(query)}`;
|
||||
const normalizedProvider = (provider || "squidwtf").toLowerCase();
|
||||
let searchUrl = "";
|
||||
|
||||
if (normalizedProvider === "squidwtf" || normalizedProvider === "tidal") {
|
||||
const data = await API.getSquidWTFBaseUrl();
|
||||
const baseUrl = data.baseUrl;
|
||||
searchUrl = `${baseUrl}/search/?s=${encodeURIComponent(query)}`;
|
||||
} else if (normalizedProvider === "deezer") {
|
||||
searchUrl = `https://www.deezer.com/search/${encodeURIComponent(query)}`;
|
||||
} else if (normalizedProvider === "qobuz") {
|
||||
searchUrl = `https://www.qobuz.com/search?query=${encodeURIComponent(query)}`;
|
||||
} else {
|
||||
const data = await API.getSquidWTFBaseUrl();
|
||||
const baseUrl = data.baseUrl;
|
||||
searchUrl = `${baseUrl}/search/?s=${encodeURIComponent(query)}`;
|
||||
}
|
||||
|
||||
window.open(searchUrl, "_blank");
|
||||
} catch (error) {
|
||||
console.error("Failed to get SquidWTF base URL:", error);
|
||||
// Fallback to first encoded URL (triton)
|
||||
showToast("Failed to get SquidWTF URL, using fallback", "warning");
|
||||
console.error("Failed to open provider search:", error);
|
||||
showToast("Failed to open provider search link", "warning");
|
||||
}
|
||||
}
|
||||
|
||||
+104
-29
@@ -34,17 +34,13 @@ import {
|
||||
} from "./playlist-admin.js";
|
||||
import { initScrobblingAdmin } from "./scrobbling-admin.js";
|
||||
import { initAuthSession } from "./auth-session.js";
|
||||
import { initActionDispatcher } from "./action-dispatcher.js";
|
||||
import { initNavigationView } from "./views/navigation-view.js";
|
||||
import { initScrobblingView } from "./views/scrobbling-view.js";
|
||||
|
||||
let cookieDateInitialized = false;
|
||||
let restartRequired = false;
|
||||
|
||||
window.showToast = showToast;
|
||||
window.escapeHtml = escapeHtml;
|
||||
window.escapeJs = escapeJs;
|
||||
window.openModal = openModal;
|
||||
window.closeModal = closeModal;
|
||||
window.capitalizeProvider = capitalizeProvider;
|
||||
|
||||
window.showRestartBanner = function () {
|
||||
restartRequired = true;
|
||||
document.getElementById("restart-banner")?.classList.add("active");
|
||||
@@ -58,17 +54,30 @@ window.switchTab = function (tabName) {
|
||||
document
|
||||
.querySelectorAll(".tab")
|
||||
.forEach((tab) => tab.classList.remove("active"));
|
||||
document
|
||||
.querySelectorAll(".sidebar-link")
|
||||
.forEach((link) => link.classList.remove("active"));
|
||||
document
|
||||
.querySelectorAll(".tab-content")
|
||||
.forEach((content) => content.classList.remove("active"));
|
||||
|
||||
const tab = document.querySelector(`.tab[data-tab="${tabName}"]`);
|
||||
const sidebarLink = document.querySelector(
|
||||
`.sidebar-link[data-tab="${tabName}"]`,
|
||||
);
|
||||
const content = document.getElementById(`tab-${tabName}`);
|
||||
|
||||
if (tab && content) {
|
||||
tab.classList.add("active");
|
||||
if (sidebarLink) {
|
||||
sidebarLink.classList.add("active");
|
||||
}
|
||||
content.classList.add("active");
|
||||
window.location.hash = tabName;
|
||||
|
||||
if (tabName === "kept" && typeof window.fetchDownloads === "function") {
|
||||
window.fetchDownloads();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -138,46 +147,112 @@ const authSession = initAuthSession({
|
||||
},
|
||||
});
|
||||
|
||||
window.viewTracks = viewTracks;
|
||||
window.openManualMap = openManualMap;
|
||||
window.openExternalMap = openExternalMap;
|
||||
window.openMapToLocal = openManualMap;
|
||||
window.openMapToExternal = openExternalMap;
|
||||
window.openModal = openModal;
|
||||
window.closeModal = closeModal;
|
||||
window.searchJellyfinTracks = searchJellyfinTracks;
|
||||
window.selectJellyfinTrack = selectJellyfinTrack;
|
||||
window.saveLocalMapping = saveLocalMapping;
|
||||
window.saveManualMapping = saveManualMapping;
|
||||
window.searchExternalTracks = searchExternalTracks;
|
||||
window.selectExternalTrack = selectExternalTrack;
|
||||
window.validateExternalMapping = validateExternalMapping;
|
||||
window.openLyricsMap = openLyricsMap;
|
||||
window.saveLyricsMapping = saveLyricsMapping;
|
||||
window.searchProvider = searchProvider;
|
||||
window.validateExternalMapping = validateExternalMapping;
|
||||
window.saveLyricsMapping = saveLyricsMapping;
|
||||
// Note: viewTracks/selectExternalTrack/selectJellyfinTrack/openLyricsMap/searchProvider
|
||||
// are now wired via the ActionDispatcher and no longer require window exports.
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
console.log("🚀 Allstarr Admin UI (Modular) loaded");
|
||||
|
||||
document.querySelectorAll(".tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
window.switchTab(tab.dataset.tab);
|
||||
});
|
||||
const dispatcher = initActionDispatcher({ root: document });
|
||||
// Register a few core actions first; more will be migrated as inline
|
||||
// onclick handlers are removed from HTML and generated markup.
|
||||
dispatcher.register("switchTab", ({ args }) => {
|
||||
const tab = args?.tab || args?.tabName;
|
||||
if (tab) {
|
||||
window.switchTab(tab);
|
||||
}
|
||||
});
|
||||
dispatcher.register("logoutAdminSession", () => window.logoutAdminSession?.());
|
||||
dispatcher.register("dismissRestartBanner", () =>
|
||||
window.dismissRestartBanner?.(),
|
||||
);
|
||||
dispatcher.register("restartContainer", () => window.restartContainer?.());
|
||||
dispatcher.register("refreshPlaylists", () => window.refreshPlaylists?.());
|
||||
dispatcher.register("clearCache", () => window.clearCache?.());
|
||||
dispatcher.register("openAddPlaylist", () => window.openAddPlaylist?.());
|
||||
dispatcher.register("toggleRowMenu", ({ event, args }) =>
|
||||
window.toggleRowMenu?.(event, args?.menuId),
|
||||
);
|
||||
dispatcher.register("toggleDetailsRow", ({ event, args }) =>
|
||||
window.toggleDetailsRow?.(event, args?.detailsRowId),
|
||||
);
|
||||
dispatcher.register("viewTracks", ({ args }) => viewTracks(args?.playlistName));
|
||||
dispatcher.register("refreshPlaylist", ({ args }) =>
|
||||
window.refreshPlaylist?.(args?.playlistName),
|
||||
);
|
||||
dispatcher.register("matchPlaylistTracks", ({ args }) =>
|
||||
window.matchPlaylistTracks?.(args?.playlistName),
|
||||
);
|
||||
dispatcher.register("clearPlaylistCache", ({ args }) =>
|
||||
window.clearPlaylistCache?.(args?.playlistName),
|
||||
);
|
||||
dispatcher.register("editPlaylistSchedule", ({ args }) =>
|
||||
window.editPlaylistSchedule?.(args?.playlistName, args?.syncSchedule),
|
||||
);
|
||||
dispatcher.register("removePlaylist", ({ args }) =>
|
||||
window.removePlaylist?.(args?.playlistName),
|
||||
);
|
||||
dispatcher.register("openLinkPlaylist", ({ args }) =>
|
||||
window.openLinkPlaylist?.(args?.jellyfinId, args?.jellyfinName),
|
||||
);
|
||||
dispatcher.register("unlinkPlaylist", ({ args }) =>
|
||||
window.unlinkPlaylist?.(args?.jellyfinId, args?.jellyfinName),
|
||||
);
|
||||
dispatcher.register("fetchJellyfinPlaylists", () =>
|
||||
window.fetchJellyfinPlaylists?.(),
|
||||
);
|
||||
dispatcher.register("searchProvider", ({ args }) =>
|
||||
searchProvider(args?.query, args?.provider),
|
||||
);
|
||||
dispatcher.register("openLyricsMap", ({ args, toNumber }) =>
|
||||
openLyricsMap(
|
||||
args?.artist,
|
||||
args?.title,
|
||||
args?.album,
|
||||
toNumber(args?.durationSeconds) ?? 0,
|
||||
),
|
||||
);
|
||||
dispatcher.register("selectJellyfinTrack", ({ args }) =>
|
||||
selectJellyfinTrack(args?.jellyfinId),
|
||||
);
|
||||
dispatcher.register("selectExternalTrack", ({ args, toNumber }) =>
|
||||
selectExternalTrack(
|
||||
toNumber(args?.resultIndex),
|
||||
args?.externalId,
|
||||
args?.title,
|
||||
args?.artist,
|
||||
args?.provider,
|
||||
args?.externalUrl,
|
||||
),
|
||||
);
|
||||
dispatcher.register("downloadFile", ({ args }) =>
|
||||
window.downloadFile?.(args?.path),
|
||||
);
|
||||
dispatcher.register("deleteDownload", ({ args }) =>
|
||||
window.deleteDownload?.(args?.path),
|
||||
);
|
||||
|
||||
const hash = window.location.hash.substring(1);
|
||||
if (hash) {
|
||||
window.switchTab(hash);
|
||||
}
|
||||
initNavigationView({ switchTab: window.switchTab });
|
||||
|
||||
setupModalBackdropClose();
|
||||
|
||||
const scrobblingTab = document.querySelector('.tab[data-tab="scrobbling"]');
|
||||
if (scrobblingTab) {
|
||||
scrobblingTab.addEventListener("click", () => {
|
||||
if (authSession.isAuthenticated()) {
|
||||
window.loadScrobblingConfig();
|
||||
}
|
||||
});
|
||||
}
|
||||
initScrobblingView({
|
||||
isAuthenticated: () => authSession.isAuthenticated(),
|
||||
loadScrobblingConfig: () => window.loadScrobblingConfig?.(),
|
||||
});
|
||||
|
||||
authSession.bootstrapAuth();
|
||||
});
|
||||
|
||||
@@ -1,17 +1,100 @@
|
||||
// Modal management
|
||||
const modalState = new Map();
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
function getModal(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function getFocusableElements(modal) {
|
||||
return Array.from(modal.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
|
||||
(el) => !el.hasAttribute("disabled") && !el.getAttribute("aria-hidden"),
|
||||
);
|
||||
}
|
||||
|
||||
function onModalKeyDown(event, modal) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeModal(modal.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "Tab") {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusable = getFocusableElements(modal);
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const isShift = event.shiftKey;
|
||||
|
||||
if (isShift && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!isShift && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
export function openModal(id) {
|
||||
document.getElementById(id).classList.add('active');
|
||||
const modal = getModal(id);
|
||||
if (!modal) return;
|
||||
|
||||
const modalContent = modal.querySelector(".modal-content");
|
||||
if (!modalContent) return;
|
||||
|
||||
const previousActive = document.activeElement;
|
||||
modalState.set(id, { previousActive });
|
||||
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.removeAttribute("aria-hidden");
|
||||
modal.classList.add("active");
|
||||
|
||||
const keydownHandler = (event) => onModalKeyDown(event, modal);
|
||||
modalState.set(id, { previousActive, keydownHandler });
|
||||
modal.addEventListener("keydown", keydownHandler);
|
||||
|
||||
const focusable = getFocusableElements(modalContent);
|
||||
if (focusable.length > 0) {
|
||||
focusable[0].focus();
|
||||
} else {
|
||||
modalContent.setAttribute("tabindex", "-1");
|
||||
modalContent.focus();
|
||||
}
|
||||
}
|
||||
|
||||
export function closeModal(id) {
|
||||
document.getElementById(id).classList.remove('active');
|
||||
const modal = getModal(id);
|
||||
if (!modal) return;
|
||||
|
||||
modal.classList.remove("active");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
|
||||
const state = modalState.get(id);
|
||||
if (state?.keydownHandler) {
|
||||
modal.removeEventListener("keydown", state.keydownHandler);
|
||||
}
|
||||
|
||||
if (state?.previousActive && typeof state.previousActive.focus === "function") {
|
||||
state.previousActive.focus();
|
||||
}
|
||||
|
||||
modalState.delete(id);
|
||||
}
|
||||
|
||||
export function setupModalBackdropClose() {
|
||||
document.querySelectorAll('.modal').forEach(modal => {
|
||||
modal.addEventListener('click', e => {
|
||||
if (e.target === modal) closeModal(modal.id);
|
||||
});
|
||||
document.querySelectorAll(".modal").forEach((modal) => {
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) closeModal(modal.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,6 +77,20 @@ function downloadAllKept() {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAllKept() {
|
||||
const result = await runAction({
|
||||
confirmMessage:
|
||||
"Delete ALL kept downloads?\n\nThis will permanently remove all kept audio files.",
|
||||
task: () => API.deleteAllDownloads(),
|
||||
success: (data) => data.message || "All kept downloads deleted",
|
||||
error: (err) => err.message || "Failed to delete all kept downloads",
|
||||
});
|
||||
|
||||
if (result) {
|
||||
await fetchDownloads();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDownload(path) {
|
||||
const result = await runAction({
|
||||
confirmMessage: `Delete this file?\n\n${path}\n\nThis action cannot be undone.`,
|
||||
@@ -364,6 +378,7 @@ export function initOperations(options) {
|
||||
window.deleteTrackMapping = deleteTrackMapping;
|
||||
window.downloadFile = downloadFile;
|
||||
window.downloadAllKept = downloadAllKept;
|
||||
window.deleteAllKept = deleteAllKept;
|
||||
window.deleteDownload = deleteDownload;
|
||||
window.refreshPlaylists = refreshPlaylists;
|
||||
window.refreshPlaylist = refreshPlaylist;
|
||||
|
||||
@@ -70,7 +70,12 @@ async function openLinkPlaylist(jellyfinId, name) {
|
||||
}
|
||||
|
||||
try {
|
||||
spotifyUserPlaylists = await API.fetchSpotifyUserPlaylists(selectedUserId);
|
||||
const response = await API.fetchSpotifyUserPlaylists(selectedUserId);
|
||||
spotifyUserPlaylists = Array.isArray(response?.playlists)
|
||||
? response.playlists
|
||||
: Array.isArray(response)
|
||||
? response
|
||||
: [];
|
||||
spotifyUserPlaylistsScopeUserId = selectedUserId;
|
||||
const availablePlaylists = spotifyUserPlaylists.filter((p) => !p.isLinked);
|
||||
|
||||
|
||||
+86
-30
@@ -3,6 +3,8 @@
|
||||
import { escapeHtml, escapeJs, capitalizeProvider } from "./utils.js";
|
||||
|
||||
let rowMenuHandlersBound = false;
|
||||
let tableRowHandlersBound = false;
|
||||
const expandedInjectedPlaylistDetails = new Set();
|
||||
|
||||
function bindRowMenuHandlers() {
|
||||
if (rowMenuHandlersBound) {
|
||||
@@ -16,6 +18,41 @@ function bindRowMenuHandlers() {
|
||||
rowMenuHandlersBound = true;
|
||||
}
|
||||
|
||||
function bindTableRowHandlers() {
|
||||
if (tableRowHandlersBound) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const detailsTrigger = event.target.closest?.(
|
||||
"button.details-trigger[data-details-target]",
|
||||
);
|
||||
if (detailsTrigger) {
|
||||
const target = detailsTrigger.getAttribute("data-details-target");
|
||||
if (target) {
|
||||
toggleDetailsRow(event, target);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const row = event.target.closest?.("tr.compact-row[data-details-row]");
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest("button, a, .row-actions-menu")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detailsRowId = row.getAttribute("data-details-row");
|
||||
if (detailsRowId) {
|
||||
toggleDetailsRow(null, detailsRowId);
|
||||
}
|
||||
});
|
||||
|
||||
tableRowHandlersBound = true;
|
||||
}
|
||||
|
||||
function closeAllRowMenus(exceptId = null) {
|
||||
document.querySelectorAll(".row-actions-menu.open").forEach((menu) => {
|
||||
if (!exceptId || menu.id !== exceptId) {
|
||||
@@ -82,6 +119,18 @@ function toggleDetailsRow(event, detailsRowId) {
|
||||
);
|
||||
if (parentRow) {
|
||||
parentRow.classList.toggle("expanded", isExpanded);
|
||||
|
||||
// Persist Injected Playlists details expansion across auto-refreshes.
|
||||
if (parentRow.closest("#playlist-table-body")) {
|
||||
const detailsKey = parentRow.getAttribute("data-details-key");
|
||||
if (detailsKey) {
|
||||
if (isExpanded) {
|
||||
expandedInjectedPlaylistDetails.add(detailsKey);
|
||||
} else {
|
||||
expandedInjectedPlaylistDetails.delete(detailsKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,11 +232,15 @@ if (typeof window !== "undefined") {
|
||||
}
|
||||
|
||||
bindRowMenuHandlers();
|
||||
bindTableRowHandlers();
|
||||
|
||||
export function updateStatusUI(data) {
|
||||
const versionEl = document.getElementById("version");
|
||||
if (versionEl) versionEl.textContent = "v" + data.version;
|
||||
|
||||
const sidebarVersionEl = document.getElementById("sidebar-version");
|
||||
if (sidebarVersionEl) sidebarVersionEl.textContent = "v" + data.version;
|
||||
|
||||
const backendTypeEl = document.getElementById("backend-type");
|
||||
if (backendTypeEl) backendTypeEl.textContent = data.backendType;
|
||||
|
||||
@@ -271,6 +324,7 @@ export function updatePlaylistsUI(data) {
|
||||
const playlists = data.playlists || [];
|
||||
|
||||
if (playlists.length === 0) {
|
||||
expandedInjectedPlaylistDetails.clear();
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Link Playlists tab.</td></tr>';
|
||||
renderGuidance("playlists-guidance", [
|
||||
@@ -329,9 +383,12 @@ export function updatePlaylistsUI(data) {
|
||||
const summary = getPlaylistStatusSummary(playlist);
|
||||
const detailsRowId = `playlist-details-${index}`;
|
||||
const menuId = `playlist-menu-${index}`;
|
||||
const detailsKey = `${playlist.id || playlist.name || index}`;
|
||||
const isExpanded = expandedInjectedPlaylistDetails.has(detailsKey);
|
||||
const syncSchedule = playlist.syncSchedule || "0 8 * * *";
|
||||
const escapedPlaylistName = escapeJs(playlist.name);
|
||||
const escapedSyncSchedule = escapeJs(syncSchedule);
|
||||
const escapedPlaylistName = escapeHtml(playlist.name);
|
||||
const escapedSyncSchedule = escapeHtml(syncSchedule);
|
||||
const escapedDetailsKey = escapeHtml(detailsKey);
|
||||
|
||||
const breakdownBadges = [
|
||||
`<span class="status-pill neutral">${summary.localCount} Local</span>`,
|
||||
@@ -345,7 +402,7 @@ export function updatePlaylistsUI(data) {
|
||||
}
|
||||
|
||||
return `
|
||||
<tr class="compact-row" data-details-row="${detailsRowId}" onclick="onCompactRowClick(event, '${detailsRowId}')">
|
||||
<tr class="compact-row ${isExpanded ? "expanded" : ""}" data-details-row="${detailsRowId}" data-details-key="${escapedDetailsKey}">
|
||||
<td>
|
||||
<div class="name-cell">
|
||||
<strong>${escapeHtml(playlist.name)}</strong>
|
||||
@@ -358,24 +415,23 @@ export function updatePlaylistsUI(data) {
|
||||
</td>
|
||||
<td><span class="status-pill ${summary.statusClass}">${summary.statusLabel}</span></td>
|
||||
<td class="row-controls">
|
||||
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="false"
|
||||
onclick="toggleDetailsRow(event, '${detailsRowId}')">Details</button>
|
||||
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="${isExpanded ? "true" : "false"}">${isExpanded ? "Hide" : "Details"}</button>
|
||||
<div class="row-actions-wrap">
|
||||
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false"
|
||||
onclick="toggleRowMenu(event, '${menuId}')">...</button>
|
||||
data-action="toggleRowMenu" data-arg-menu-id="${menuId}">...</button>
|
||||
<div class="row-actions-menu" id="${menuId}" role="menu">
|
||||
<button onclick="closeRowMenu(event, '${menuId}'); viewTracks('${escapedPlaylistName}')">View Tracks</button>
|
||||
<button onclick="closeRowMenu(event, '${menuId}'); refreshPlaylist('${escapedPlaylistName}')">Refresh</button>
|
||||
<button onclick="closeRowMenu(event, '${menuId}'); matchPlaylistTracks('${escapedPlaylistName}')">Rematch</button>
|
||||
<button onclick="closeRowMenu(event, '${menuId}'); clearPlaylistCache('${escapedPlaylistName}')">Rebuild</button>
|
||||
<button onclick="closeRowMenu(event, '${menuId}'); editPlaylistSchedule('${escapedPlaylistName}', '${escapedSyncSchedule}')">Edit Schedule</button>
|
||||
<button data-action="viewTracks" data-arg-playlist-name="${escapedPlaylistName}">View Tracks</button>
|
||||
<button data-action="refreshPlaylist" data-arg-playlist-name="${escapedPlaylistName}">Refresh</button>
|
||||
<button data-action="matchPlaylistTracks" data-arg-playlist-name="${escapedPlaylistName}">Rematch</button>
|
||||
<button data-action="clearPlaylistCache" data-arg-playlist-name="${escapedPlaylistName}">Rebuild</button>
|
||||
<button data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit Schedule</button>
|
||||
<hr>
|
||||
<button class="danger-item" onclick="closeRowMenu(event, '${menuId}'); removePlaylist('${escapedPlaylistName}')">Remove Playlist</button>
|
||||
<button class="danger-item" data-action="removePlaylist" data-arg-playlist-name="${escapedPlaylistName}">Remove Playlist</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="${detailsRowId}" class="details-row" hidden>
|
||||
<tr id="${detailsRowId}" class="details-row" ${isExpanded ? "" : "hidden"}>
|
||||
<td colspan="4">
|
||||
<div class="details-panel">
|
||||
<div class="details-grid">
|
||||
@@ -383,7 +439,7 @@ export function updatePlaylistsUI(data) {
|
||||
<span class="detail-label">Sync Schedule</span>
|
||||
<span class="detail-value mono">
|
||||
${escapeHtml(syncSchedule)}
|
||||
<button class="inline-action-link" onclick="editPlaylistSchedule('${escapedPlaylistName}', '${escapedSyncSchedule}')">Edit</button>
|
||||
<button class="inline-action-link" data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
@@ -478,9 +534,9 @@ export function updateDownloadsUI(data) {
|
||||
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
|
||||
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
|
||||
<td>
|
||||
<button onclick="downloadFile('${escapeJs(f.path)}')"
|
||||
<button data-action="downloadFile" data-arg-path="${escapeHtml(escapeJs(f.path))}"
|
||||
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button>
|
||||
<button onclick="deleteDownload('${escapeJs(f.path)}')"
|
||||
<button data-action="deleteDownload" data-arg-path="${escapeHtml(escapeJs(f.path))}"
|
||||
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -634,26 +690,27 @@ export function updateJellyfinPlaylistsUI(data) {
|
||||
.map((playlist, index) => {
|
||||
const detailsRowId = `jellyfin-details-${index}`;
|
||||
const menuId = `jellyfin-menu-${index}`;
|
||||
const statsPending = Boolean(playlist.statsPending);
|
||||
const localCount = playlist.localTracks || 0;
|
||||
const externalCount = playlist.externalTracks || 0;
|
||||
const externalAvailable = playlist.externalAvailable || 0;
|
||||
const escapedId = escapeJs(playlist.id);
|
||||
const escapedName = escapeJs(playlist.name);
|
||||
const escapedId = escapeHtml(playlist.id);
|
||||
const escapedName = escapeHtml(playlist.name);
|
||||
const statusClass = playlist.isConfigured ? "success" : "info";
|
||||
const statusLabel = playlist.isConfigured ? "Linked" : "Not Linked";
|
||||
|
||||
const actionButtons = playlist.isConfigured
|
||||
? `
|
||||
<button onclick="closeRowMenu(event, '${menuId}'); fetchJellyfinPlaylists()">Refresh Row Data</button>
|
||||
<button class="danger-item" onclick="closeRowMenu(event, '${menuId}'); unlinkPlaylist('${escapedId}', '${escapedName}')">Unlink from Spotify</button>
|
||||
<button data-action="fetchJellyfinPlaylists">Refresh Row Data</button>
|
||||
<button class="danger-item" data-action="unlinkPlaylist" data-arg-jellyfin-id="${escapedId}" data-arg-jellyfin-name="${escapedName}">Unlink from Spotify</button>
|
||||
`
|
||||
: `
|
||||
<button onclick="closeRowMenu(event, '${menuId}'); openLinkPlaylist('${escapedId}', '${escapedName}')">Link to Spotify</button>
|
||||
<button onclick="closeRowMenu(event, '${menuId}'); fetchJellyfinPlaylists()">Refresh Row Data</button>
|
||||
<button data-action="openLinkPlaylist" data-arg-jellyfin-id="${escapedId}" data-arg-jellyfin-name="${escapedName}">Link to Spotify</button>
|
||||
<button data-action="fetchJellyfinPlaylists">Refresh Row Data</button>
|
||||
`;
|
||||
|
||||
return `
|
||||
<tr class="compact-row" data-details-row="${detailsRowId}" onclick="onCompactRowClick(event, '${detailsRowId}')">
|
||||
<tr class="compact-row" data-details-row="${detailsRowId}">
|
||||
<td>
|
||||
<div class="name-cell">
|
||||
<strong>${escapeHtml(playlist.name)}</strong>
|
||||
@@ -661,16 +718,15 @@ export function updateJellyfinPlaylistsUI(data) {
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="track-count">${localCount + externalAvailable}</span>
|
||||
<div class="meta-text">L ${localCount} • E ${externalAvailable}/${externalCount}</div>
|
||||
<span class="track-count">${statsPending ? "..." : localCount + externalAvailable}</span>
|
||||
<div class="meta-text">${statsPending ? "Loading track stats..." : `L ${localCount} • E ${externalAvailable}/${externalCount}`}</div>
|
||||
</td>
|
||||
<td><span class="status-pill ${statusClass}">${statusLabel}</span></td>
|
||||
<td class="row-controls">
|
||||
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="false"
|
||||
onclick="toggleDetailsRow(event, '${detailsRowId}')">Details</button>
|
||||
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="false">Details</button>
|
||||
<div class="row-actions-wrap">
|
||||
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false"
|
||||
onclick="toggleRowMenu(event, '${menuId}')">...</button>
|
||||
data-action="toggleRowMenu" data-arg-menu-id="${menuId}">...</button>
|
||||
<div class="row-actions-menu" id="${menuId}" role="menu">
|
||||
${actionButtons}
|
||||
</div>
|
||||
@@ -683,11 +739,11 @@ export function updateJellyfinPlaylistsUI(data) {
|
||||
<div class="details-grid">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Local Tracks</span>
|
||||
<span class="detail-value">${localCount}</span>
|
||||
<span class="detail-value">${statsPending ? "..." : localCount}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">External Tracks</span>
|
||||
<span class="detail-value">${externalAvailable}/${externalCount}</span>
|
||||
<span class="detail-value">${statsPending ? "Loading..." : `${externalAvailable}/${externalCount}`}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Linked Spotify ID</span>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
This folder contains small “view” modules for the admin UI.
|
||||
|
||||
Goal: keep `js/main.js` as orchestration only, while view modules encapsulate DOM wiring for each section.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export function initNavigationView({ switchTab } = {}) {
|
||||
const doSwitch =
|
||||
typeof switchTab === "function" ? switchTab : (tab) => window.switchTab?.(tab);
|
||||
|
||||
document.querySelectorAll(".tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
doSwitch(tab.dataset.tab);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".sidebar-link").forEach((link) => {
|
||||
link.addEventListener("click", () => {
|
||||
doSwitch(link.dataset.tab);
|
||||
});
|
||||
});
|
||||
|
||||
const hash = window.location.hash.substring(1);
|
||||
if (hash) {
|
||||
doSwitch(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export function initScrobblingView({
|
||||
isAuthenticated,
|
||||
loadScrobblingConfig,
|
||||
} = {}) {
|
||||
const canLoad =
|
||||
typeof isAuthenticated === "function" ? isAuthenticated : () => false;
|
||||
const load =
|
||||
typeof loadScrobblingConfig === "function"
|
||||
? loadScrobblingConfig
|
||||
: () => window.loadScrobblingConfig?.();
|
||||
|
||||
function onActivateScrobbling() {
|
||||
if (canLoad()) {
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
const scrobblingTab = document.querySelector('.tab[data-tab="scrobbling"]');
|
||||
if (scrobblingTab) {
|
||||
scrobblingTab.addEventListener("click", onActivateScrobbling);
|
||||
}
|
||||
|
||||
const scrobblingSidebar = document.querySelector(
|
||||
'.sidebar-link[data-tab="scrobbling"]',
|
||||
);
|
||||
if (scrobblingSidebar) {
|
||||
scrobblingSidebar.addEventListener("click", onActivateScrobbling);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Spotify Track Mappings - Allstarr</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0d1117;
|
||||
@@ -41,6 +42,26 @@
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.support-footer {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto 24px;
|
||||
padding: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.92rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.support-footer a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.support-footer a:hover {
|
||||
color: var(--accent-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -646,5 +667,32 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="support-footer">
|
||||
<p class="support-text">
|
||||
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
|
||||
supporting its development
|
||||
</p>
|
||||
<ul class="support-funding-icons">
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://ko-fi.com/joshpatra" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="Support on Ko-fi">
|
||||
<img src="images/kofi_symbol.svg" alt="" width="37" height="30" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://github.com/sponsors/SoPat712" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="GitHub Sponsors">
|
||||
<img src="images/github-mark.svg" alt="" width="30" height="29" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://buymeacoffee.com/treeman183" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="Buy Me a Coffee">
|
||||
<img src="images/buymeacoffee-symbol.svg" alt="" width="30" height="30" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -15,6 +15,7 @@ let localMapContext = null;
|
||||
let localMapResults = [];
|
||||
let localMapSelectedIndex = -1;
|
||||
let externalMapContext = null;
|
||||
const modalFocusState = new Map();
|
||||
|
||||
function showToast(message, type = "success", duration = 3000) {
|
||||
const toast = document.createElement("div");
|
||||
@@ -247,9 +248,26 @@ function toggleModal(modalId, shouldOpen) {
|
||||
}
|
||||
|
||||
if (shouldOpen) {
|
||||
const previousActive = document.activeElement;
|
||||
modalFocusState.set(modalId, previousActive);
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.removeAttribute("aria-hidden");
|
||||
modal.classList.add("active");
|
||||
const firstFocusable = modal.querySelector(
|
||||
'button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
if (firstFocusable) {
|
||||
firstFocusable.focus();
|
||||
}
|
||||
} else {
|
||||
modal.classList.remove("active");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
const previousActive = modalFocusState.get(modalId);
|
||||
if (previousActive && typeof previousActive.focus === "function") {
|
||||
previousActive.focus();
|
||||
}
|
||||
modalFocusState.delete(modalId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -627,6 +645,10 @@ function initializeEventListeners() {
|
||||
closeLocalMapModal();
|
||||
closeExternalMapModal();
|
||||
});
|
||||
|
||||
document.querySelectorAll(".modal-overlay").forEach((modal) => {
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
|
||||
@@ -69,12 +69,192 @@ body {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.support-badge {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: rgba(22, 27, 34, 0.94);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.28);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.support-badge a,
|
||||
.support-footer a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.support-badge a:hover,
|
||||
.support-footer a:hover {
|
||||
color: var(--accent-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.support-text {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.support-funding-icons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.support-funding-icons li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.support-badge .support-funding-icons {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.support-footer .support-funding-icons {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.support-funding-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.9;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.support-badge .support-funding-link:hover,
|
||||
.support-footer .support-funding-link:hover {
|
||||
opacity: 1;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.support-funding-link img {
|
||||
display: block;
|
||||
height: 30px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: rgba(22, 27, 34, 0.8);
|
||||
backdrop-filter: blur(8px);
|
||||
padding: 14px;
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.sidebar-subtitle {
|
||||
margin-top: 2px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
font-family: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono",
|
||||
monospace;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 9px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sidebar-link:hover {
|
||||
background: rgba(33, 38, 45, 0.7);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-link.active {
|
||||
background: rgba(88, 166, 255, 0.12);
|
||||
border-color: rgba(88, 166, 255, 0.35);
|
||||
color: #9ecbff;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sidebar-footer button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.top-tabs,
|
||||
.tabs.top-tabs {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.support-footer {
|
||||
margin-top: 8px;
|
||||
padding: 20px 0 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.92rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -859,6 +1039,31 @@ input::placeholder {
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.support-badge {
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
width: min(340px, calc(100vw - 24px));
|
||||
padding: 10px 12px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.support-footer {
|
||||
padding-top: 16px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
@@ -867,6 +1072,140 @@ input::placeholder {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Utility classes to reduce inline styles in index.html */
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mb-16 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mt-8 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mt-12 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flex-row-wrap {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.flex-row-wrap-8 {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.summary-box {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-weight: 600;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.summary-value.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.summary-value.warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.summary-value.accent {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.callout {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.callout.warning {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
border-color: var(--warning);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.callout.warning-strong {
|
||||
background: rgba(255, 193, 7, 0.15);
|
||||
border-color: #ffc107;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.callout.danger {
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
border-color: var(--error);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pill-card {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stats-grid-auto {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.max-h-600 {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.jellyfin-user-form-group {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.jellyfin-user-form-group label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.tracks-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
|
||||
Reference in New Issue
Block a user