mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-23 10:42:37 -04:00
Compare commits
41 Commits
v1.5.3
...
v1.5.1-beta.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
3c291d5fac
|
|||
|
2a430a1c38
|
|||
|
1a0f7c0282
|
|||
|
6b89fe548f
|
|||
|
233af5dc8f
|
|||
|
4c1e6979b3
|
|||
|
0738e2d588
|
|||
|
5e8cb13d1a
|
|||
|
efdeef927a
|
|||
|
30f68729fc
|
|||
|
53f7b5e8b3
|
|||
|
da33ba9fbd
|
|||
|
6c95cfd2d6
|
|||
|
50157db484
|
|||
|
2d11d913e8
|
|||
|
f9e5b7f323
|
|||
|
db714fee2d
|
|||
|
efe1660d81
|
|||
|
639070556a
|
|||
|
00a5d152a5
|
|||
|
1ba6135115
|
|||
|
ec994773dd
|
|||
|
39c8f16b59
|
|||
|
a6a423d5a1
|
|||
|
899451d405
|
|||
|
8d6dd7ccf1
|
|||
|
ebdd8d4e2a
|
|||
|
e4599a419e
|
|||
|
86290dff0d
|
|||
|
0a9e528418
|
|||
|
f74728fc73
|
|||
|
87467be61b
|
|||
|
713ecd4ec8
|
|||
|
0ff1e3a428
|
|||
|
cef18b9482
|
|||
|
1bfe30b216
|
|||
|
c9c82a650d
|
|||
|
d0a7dbcc96
|
|||
|
9c9a827a91
|
|||
|
96889738df
|
|||
|
f3c791496e
|
@@ -73,7 +73,7 @@ jobs:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=sha,prefix=
|
||||
type=ref,event=tag
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
|
||||
@@ -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.0";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+92
-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,67 @@
|
||||
<div class="auth-error" id="auth-error" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="support-badge">
|
||||
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
|
||||
supporting its development via
|
||||
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
|
||||
or
|
||||
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container" id="main-container" style="display:none;">
|
||||
<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 +141,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 +158,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 +166,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 +244,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 +280,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 +320,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 +358,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 +972,18 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="support-footer">
|
||||
<p>
|
||||
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
|
||||
supporting its development via
|
||||
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
|
||||
or
|
||||
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
|
||||
</p>
|
||||
</footer>
|
||||
</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,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="support-footer">
|
||||
<p>
|
||||
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
|
||||
supporting its development via
|
||||
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
|
||||
or
|
||||
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
|
||||
</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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,144 @@ 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;
|
||||
}
|
||||
|
||||
.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 +991,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 +1024,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